Merge pull request #1 from daxdax89/improvement

Improvement
This commit is contained in:
DaX
2025-07-21 12:16:31 +02:00
committed by GitHub
16 changed files with 754 additions and 634 deletions

View File

@@ -1,63 +0,0 @@
# Deployment Guide for Filamenteka
## Important: API Update Required
The production API at api.filamenteka.rs needs to be updated with the latest code changes.
### Changes that need to be deployed:
1. **Database Schema Changes**:
- Column renamed from `vakum` to `spulna`
- Column `otvoreno` has been removed
- Data types changed from strings to integers for `refill` and `spulna`
- Added CHECK constraint: `kolicina = refill + spulna`
2. **API Server Changes**:
- Updated `/api/filaments` endpoints to use new column names
- Updated data type handling (integers instead of strings)
- Added proper quantity calculation
### Deployment Steps:
1. **Update the API server code**:
```bash
# On the production server
cd /path/to/api
git pull origin main
npm install
```
2. **Run database migrations**:
```bash
# Run the migration to rename columns
psql $DATABASE_URL < database/migrations/003_rename_vakum_to_spulna.sql
# Run the migration to fix data types
psql $DATABASE_URL < database/migrations/004_fix_inventory_data_types.sql
# Fix any data inconsistencies
psql $DATABASE_URL < database/migrations/fix_quantity_consistency.sql
```
3. **Restart the API server**:
```bash
# Restart the service
pm2 restart filamenteka-api
# or
systemctl restart filamenteka-api
```
### Temporary Frontend Compatibility
The frontend has been updated to handle both old and new API response formats, so it will work with both:
- Old format: `vakum`, `otvoreno` (strings)
- New format: `spulna` (integer), no `otvoreno` field
Once the API is updated, the compatibility layer can be removed.
### Verification
After deployment, verify:
1. API returns `spulna` instead of `vakum`
2. Values are integers, not strings
3. Quantity calculations are correct (`kolicina = refill + spulna`)

View File

@@ -1,101 +0,0 @@
# Filamenteka Deployment Guide
## Architecture
Filamenteka now uses:
- **Frontend**: Next.js deployed on AWS Amplify
- **Database**: PostgreSQL on AWS RDS (publicly accessible)
- **API**: Node.js server that can be run locally or deployed anywhere
## AWS RDS Setup
1. Navigate to the terraform directory:
```bash
cd terraform
```
2. Initialize Terraform:
```bash
terraform init
```
3. Apply the infrastructure:
```bash
terraform apply
```
4. After deployment, get the database connection details:
```bash
terraform output -json
```
5. Get the database password from AWS Secrets Manager:
```bash
aws secretsmanager get-secret-value --secret-id filamenteka-db-credentials --query SecretString --output text | jq -r .password
```
## Running the API
### Option 1: Local Development
1. Create `.env` file in the `api` directory:
```
DATABASE_URL=postgresql://filamenteka_admin:[PASSWORD]@[RDS_ENDPOINT]/filamenteka
JWT_SECRET=your-secret-key-here
ADMIN_PASSWORD=your-password-here
PORT=4000
```
2. Install dependencies and run migrations:
```bash
cd api
npm install
npm run migrate
npm run dev
```
### Option 2: Deploy on a VPS/Cloud Service
You can deploy the Node.js API on:
- Heroku
- Railway
- Render
- AWS EC2
- DigitalOcean
- Any VPS with Node.js
Just ensure the `DATABASE_URL` points to your RDS instance.
## Frontend Configuration
Update `.env.local` to point to your API:
```
NEXT_PUBLIC_API_URL=http://localhost:4000/api # For local
# or
NEXT_PUBLIC_API_URL=https://your-api-domain.com/api # For production
```
## Security Notes
1. **RDS Security**: The current configuration allows access from anywhere (0.0.0.0/0). In production:
- Update the security group to only allow your IP addresses
- Or use a VPN/bastion host
- Or deploy the API in the same VPC and restrict access
2. **API Security**:
- Change the default admin password
- Use strong JWT secrets
- Enable HTTPS in production
## Database Management
Connect to the PostgreSQL database using any client:
```
psql postgresql://filamenteka_admin:[PASSWORD]@[RDS_ENDPOINT]/filamenteka
```
Or use a GUI tool like:
- pgAdmin
- TablePlus
- DBeaver
- DataGrip

View File

@@ -1,126 +0,0 @@
# Project Structure
## Overview
Filamenteka is organized with clear separation between frontend, API, and infrastructure code.
## Directory Structure
```
filamenteka/
├── app/ # Next.js app directory
│ ├── page.tsx # Main page
│ ├── layout.tsx # Root layout
│ └── upadaj/ # Admin pages
│ ├── page.tsx # Admin login
│ ├── dashboard/ # Filament management
│ └── colors/ # Color management
├── src/ # Source code
│ ├── components/ # React components
│ │ ├── FilamentTableV2.tsx
│ │ ├── EnhancedFilters.tsx
│ │ ├── ColorSwatch.tsx
│ │ ├── InventoryBadge.tsx
│ │ └── MaterialBadge.tsx
│ ├── types/ # TypeScript types
│ │ ├── filament.ts
│ │ └── filament.v2.ts
│ ├── services/ # API services
│ │ └── api.ts
│ └── styles/ # Component styles
│ ├── index.css
│ └── select.css
├── api/ # Node.js Express API
│ ├── server.js # Express server
│ ├── migrate.js # Database migration script
│ ├── package.json # API dependencies
│ └── Dockerfile # Docker configuration
├── database/ # Database schemas
│ └── schema.sql # PostgreSQL schema
├── terraform/ # Infrastructure as Code
│ ├── main.tf # Main configuration
│ ├── vpc.tf # VPC and networking
│ ├── rds.tf # PostgreSQL RDS
│ ├── ec2-api.tf # EC2 for API server
│ ├── alb.tf # Application Load Balancer
│ ├── ecr.tf # Docker registry
│ ├── cloudflare-api.tf # Cloudflare DNS
│ └── variables.tf # Variable definitions
├── scripts/ # Utility scripts
│ ├── security/ # Security checks
│ │ └── security-check.js
│ └── pre-commit.sh # Git pre-commit hook
├── config/ # Configuration files
│ └── environments.js # Environment configuration
└── public/ # Static assets
```
## Environment Files
- `.env.development` - Development environment variables
- `.env.production` - Production environment variables
- `.env.local` - Local overrides (not committed)
## Key Concepts
### Architecture
- **Frontend**: Next.js static site hosted on AWS Amplify
- **API**: Node.js Express server running on EC2
- **Database**: PostgreSQL on AWS RDS
- **HTTPS**: Application Load Balancer with ACM certificate
### Data Flow
1. Frontend (Next.js) → HTTPS API (ALB) → Express Server (EC2) → PostgreSQL (RDS)
2. Authentication via JWT tokens
3. Real-time database synchronization
### Infrastructure
- Managed via Terraform
- AWS services: RDS, EC2, ALB, VPC, ECR, Amplify
- Cloudflare for DNS management
- Docker for API containerization
## Development Workflow
1. **Local Development**
```bash
npm run dev
```
2. **Deploy Infrastructure**
```bash
cd terraform
terraform apply
```
3. **Deploy API Updates**
- API automatically pulls latest Docker image every 5 minutes
- Or manually: SSH to EC2 and run deployment script
## Database Management
### Run Migrations
```bash
cd api
npm run migrate
```
### Connect to Database
```bash
psql postgresql://user:pass@rds-endpoint/filamenteka
```
## Security
- No hardcoded credentials
- JWT authentication for admin
- Environment-specific configurations
- Pre-commit security checks
- HTTPS everywhere
- VPC isolation for backend services

230
__tests__/api.test.ts Normal file
View File

@@ -0,0 +1,230 @@
import axios from 'axios';
import api, { authService, colorService, filamentService } from '../src/services/api';
// Get the mock axios instance that was created
const mockAxiosInstance = (axios.create as jest.Mock).mock.results[0].value;
// Mock localStorage
const localStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn(),
};
(global as any).localStorage = localStorageMock;
// Mock window.location
const mockLocation = {
pathname: '/',
href: '',
};
// Only define location if it doesn't exist or is configurable
if (!Object.getOwnPropertyDescriptor(window, 'location') ||
Object.getOwnPropertyDescriptor(window, 'location')?.configurable) {
Object.defineProperty(window, 'location', {
value: mockLocation,
configurable: true,
writable: true
});
} else {
// If location exists and is not configurable, we'll work with the existing object
Object.assign(window.location, mockLocation);
}
describe('API Service Tests', () => {
beforeEach(() => {
jest.clearAllMocks();
// Clear localStorage mocks
localStorageMock.getItem.mockClear();
localStorageMock.removeItem.mockClear();
localStorageMock.setItem.mockClear();
// Reset window location
mockLocation.pathname = '/';
mockLocation.href = '';
});
describe('Auth Service', () => {
it('should login successfully', async () => {
const mockResponse = { data: { token: 'test-token', user: 'admin' } };
mockAxiosInstance.post.mockResolvedValue(mockResponse);
const result = await authService.login('admin', 'password123');
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/login', {
username: 'admin',
password: 'password123'
});
expect(result).toEqual(mockResponse.data);
});
it('should handle login failure', async () => {
const error = new Error('Invalid credentials');
mockAxiosInstance.post.mockRejectedValue(error);
await expect(authService.login('admin', 'wrong')).rejects.toThrow('Invalid credentials');
});
});
describe('Color Service', () => {
it('should get all colors', async () => {
const mockColors = [
{ id: '1', name: 'Red', hex: '#FF0000' },
{ id: '2', name: 'Blue', hex: '#0000FF' }
];
mockAxiosInstance.get.mockResolvedValue({ data: mockColors });
const result = await colorService.getAll();
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/colors');
expect(result).toEqual(mockColors);
});
it('should create a color', async () => {
const newColor = { name: 'Green', hex: '#00FF00', cena_refill: 100, cena_spulna: 150 };
const mockResponse = { id: '3', ...newColor };
mockAxiosInstance.post.mockResolvedValue({ data: mockResponse });
const result = await colorService.create(newColor);
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/colors', newColor);
expect(result).toEqual(mockResponse);
});
it('should update a color', async () => {
const colorId = '1';
const updateData = { name: 'Dark Red', hex: '#8B0000' };
const mockResponse = { id: colorId, ...updateData };
mockAxiosInstance.put.mockResolvedValue({ data: mockResponse });
const result = await colorService.update(colorId, updateData);
expect(mockAxiosInstance.put).toHaveBeenCalledWith(`/colors/${colorId}`, updateData);
expect(result).toEqual(mockResponse);
});
it('should delete a color', async () => {
const colorId = '1';
const mockResponse = { success: true };
mockAxiosInstance.delete.mockResolvedValue({ data: mockResponse });
const result = await colorService.delete(colorId);
expect(mockAxiosInstance.delete).toHaveBeenCalledWith(`/colors/${colorId}`);
expect(result).toEqual(mockResponse);
});
});
describe('Filament Service', () => {
it('should get all filaments with cache buster', async () => {
const mockFilaments = [
{ id: '1', tip: 'PLA', boja: 'Red' },
{ id: '2', tip: 'PETG', boja: 'Blue' }
];
mockAxiosInstance.get.mockResolvedValue({ data: mockFilaments });
// Mock Date.now()
const originalDateNow = Date.now;
const mockTimestamp = 1234567890;
Date.now = jest.fn(() => mockTimestamp);
const result = await filamentService.getAll();
expect(mockAxiosInstance.get).toHaveBeenCalledWith(`/filaments?_t=${mockTimestamp}`);
expect(result).toEqual(mockFilaments);
// Restore Date.now
Date.now = originalDateNow;
});
it('should create a filament', async () => {
const newFilament = {
tip: 'ABS',
finish: 'Matte',
boja: 'Black',
boja_hex: '#000000'
};
const mockResponse = { id: '3', ...newFilament };
mockAxiosInstance.post.mockResolvedValue({ data: mockResponse });
const result = await filamentService.create(newFilament);
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/filaments', newFilament);
expect(result).toEqual(mockResponse);
});
it('should update a filament', async () => {
const filamentId = '1';
const updateData = {
tip: 'PLA+',
finish: 'Silk',
cena: '4500'
};
const mockResponse = { id: filamentId, ...updateData };
mockAxiosInstance.put.mockResolvedValue({ data: mockResponse });
const result = await filamentService.update(filamentId, updateData);
expect(mockAxiosInstance.put).toHaveBeenCalledWith(`/filaments/${filamentId}`, updateData);
expect(result).toEqual(mockResponse);
});
it('should delete a filament', async () => {
const filamentId = '1';
const mockResponse = { success: true };
mockAxiosInstance.delete.mockResolvedValue({ data: mockResponse });
const result = await filamentService.delete(filamentId);
expect(mockAxiosInstance.delete).toHaveBeenCalledWith(`/filaments/${filamentId}`);
expect(result).toEqual(mockResponse);
});
it('should update bulk sale', async () => {
const saleData = {
filamentIds: ['1', '2', '3'],
salePercentage: 20,
saleStartDate: '2024-01-01',
saleEndDate: '2024-01-31',
enableSale: true
};
const mockResponse = { updated: 3, success: true };
mockAxiosInstance.post.mockResolvedValue({ data: mockResponse });
const result = await filamentService.updateBulkSale(saleData);
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/filaments/sale/bulk', saleData);
expect(result).toEqual(mockResponse);
});
});
describe('Interceptors', () => {
it('should have interceptors configured', () => {
expect(mockAxiosInstance.interceptors).toBeDefined();
expect(mockAxiosInstance.interceptors.request).toBeDefined();
expect(mockAxiosInstance.interceptors.response).toBeDefined();
});
it('should have request interceptor set up', () => {
const mockRequestUse = mockAxiosInstance.interceptors.request.use;
expect(mockRequestUse).toBeDefined();
});
it('should have response interceptor set up', () => {
const mockResponseUse = mockAxiosInstance.interceptors.response.use;
expect(mockResponseUse).toBeDefined();
});
});
describe('API configuration', () => {
it('should export the configured axios instance', () => {
expect(api).toBe(mockAxiosInstance);
});
it('should have axios instance defined', () => {
expect(mockAxiosInstance).toBeDefined();
});
});
});

View File

@@ -0,0 +1,148 @@
import React from 'react';
import { render, fireEvent, act } from '@testing-library/react';
import { BackToTop } from '@/src/components/BackToTop';
// Mock window properties
global.scrollTo = jest.fn();
Object.defineProperty(window, 'pageYOffset', {
writable: true,
configurable: true,
value: 0,
});
describe('BackToTop', () => {
beforeEach(() => {
jest.clearAllMocks();
window.pageYOffset = 0;
});
it('does not render button when at top of page', () => {
const { container } = render(<BackToTop />);
const button = container.querySelector('button');
expect(button).not.toBeInTheDocument();
});
it('renders button when scrolled down', () => {
const { container } = render(<BackToTop />);
// Simulate scroll down
act(() => {
window.pageYOffset = 400;
fireEvent.scroll(window);
});
const button = container.querySelector('button');
expect(button).toBeInTheDocument();
});
it('hides button when scrolled back up', () => {
const { container } = render(<BackToTop />);
// Scroll down first
act(() => {
window.pageYOffset = 400;
fireEvent.scroll(window);
});
expect(container.querySelector('button')).toBeInTheDocument();
// Scroll back up
act(() => {
window.pageYOffset = 100;
fireEvent.scroll(window);
});
expect(container.querySelector('button')).not.toBeInTheDocument();
});
it('scrolls to top when clicked', () => {
const { container } = render(<BackToTop />);
// Make button visible
act(() => {
window.pageYOffset = 400;
fireEvent.scroll(window);
});
const button = container.querySelector('button');
fireEvent.click(button!);
expect(global.scrollTo).toHaveBeenCalledWith({
top: 0,
behavior: 'smooth'
});
});
it('has correct styling when visible', () => {
const { container } = render(<BackToTop />);
// Make button visible
act(() => {
window.pageYOffset = 400;
fireEvent.scroll(window);
});
const button = container.querySelector('button');
expect(button).toHaveClass('fixed');
expect(button).toHaveClass('bottom-8');
expect(button).toHaveClass('right-8');
expect(button).toHaveClass('bg-blue-600');
expect(button).toHaveClass('text-white');
expect(button).toHaveClass('rounded-full');
expect(button).toHaveClass('shadow-lg');
});
it('has hover effect', () => {
const { container } = render(<BackToTop />);
// Make button visible
act(() => {
window.pageYOffset = 400;
fireEvent.scroll(window);
});
const button = container.querySelector('button');
expect(button).toHaveClass('hover:bg-blue-700');
expect(button).toHaveClass('hover:scale-110');
});
it('contains arrow icon', () => {
const { container } = render(<BackToTop />);
// Make button visible
act(() => {
window.pageYOffset = 400;
fireEvent.scroll(window);
});
const svg = container.querySelector('svg');
expect(svg).toBeInTheDocument();
expect(svg).toHaveClass('w-6');
expect(svg).toHaveClass('h-6');
});
it('has aria-label for accessibility', () => {
const { container } = render(<BackToTop />);
// Make button visible
act(() => {
window.pageYOffset = 400;
fireEvent.scroll(window);
});
const button = container.querySelector('button');
expect(button).toHaveAttribute('aria-label', 'Back to top');
});
it('cleans up scroll listener on unmount', () => {
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
const { unmount } = render(<BackToTop />);
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function));
removeEventListenerSpy.mockRestore();
});
});

View File

@@ -0,0 +1,82 @@
import React from 'react';
import { render } from '@testing-library/react';
import { ColorCell } from '@/src/components/ColorCell';
// Mock the bambuLabColors module
jest.mock('@/src/data/bambuLabColors', () => ({
getFilamentColor: jest.fn((colorName) => {
const colors = {
'Black': { hex: '#000000' },
'Red': { hex: '#FF0000' },
'Blue': { hex: '#0000FF' },
'Rainbow': { hex: ['#FF0000', '#00FF00'], isGradient: true }
};
return colors[colorName] || { hex: '#CCCCCC' };
}),
getColorStyle: jest.fn((colorMapping) => {
if (colorMapping.isGradient && Array.isArray(colorMapping.hex)) {
return {
background: `linear-gradient(90deg, ${colorMapping.hex[0]} 0%, ${colorMapping.hex[1]} 100%)`
};
}
return {
backgroundColor: Array.isArray(colorMapping.hex) ? colorMapping.hex[0] : colorMapping.hex
};
})
}));
describe('ColorCell', () => {
it('renders color name', () => {
const { getByText } = render(<ColorCell colorName="Black" />);
expect(getByText('Black')).toBeInTheDocument();
});
it('renders color swatch with correct style', () => {
const { container } = render(<ColorCell colorName="Red" />);
const colorDiv = container.querySelector('.w-6.h-6');
expect(colorDiv).toHaveStyle({ backgroundColor: '#FF0000' });
});
it('renders with correct dimensions', () => {
const { container } = render(<ColorCell colorName="Blue" />);
const colorDiv = container.querySelector('.w-6.h-6');
expect(colorDiv).toHaveClass('w-6');
expect(colorDiv).toHaveClass('h-6');
});
it('has rounded corners and border', () => {
const { container } = render(<ColorCell colorName="Black" />);
const colorDiv = container.querySelector('.w-6.h-6');
expect(colorDiv).toHaveClass('rounded');
expect(colorDiv).toHaveClass('border');
expect(colorDiv).toHaveClass('border-gray-300');
});
it('renders gradient colors', () => {
const { container } = render(<ColorCell colorName="Rainbow" />);
const colorDiv = container.querySelector('.w-6.h-6');
expect(colorDiv).toHaveStyle({
background: 'linear-gradient(90deg, #FF0000 0%, #00FF00 100%)'
});
});
it('has title attribute with hex value', () => {
const { container } = render(<ColorCell colorName="Black" />);
const colorDiv = container.querySelector('.w-6.h-6');
expect(colorDiv).toHaveAttribute('title', '#000000');
});
it('has title attribute with gradient hex values', () => {
const { container } = render(<ColorCell colorName="Rainbow" />);
const colorDiv = container.querySelector('.w-6.h-6');
expect(colorDiv).toHaveAttribute('title', '#FF0000 - #00FF00');
});
it('renders with flex layout', () => {
const { container } = render(<ColorCell colorName="Black" />);
const wrapper = container.firstChild;
expect(wrapper).toHaveClass('flex');
expect(wrapper).toHaveClass('items-center');
expect(wrapper).toHaveClass('gap-2');
});
});

View File

@@ -0,0 +1,87 @@
import React from 'react';
import { render } from '@testing-library/react';
import { MaterialBadge } from '@/src/components/MaterialBadge';
describe('MaterialBadge', () => {
describe('Material type badges', () => {
it('renders PLA badge with correct style', () => {
const { getByText } = render(<MaterialBadge base="PLA" />);
const badge = getByText('PLA');
expect(badge).toHaveClass('bg-green-100');
expect(badge).toHaveClass('text-green-800');
});
it('renders PETG badge with correct style', () => {
const { getByText } = render(<MaterialBadge base="PETG" />);
const badge = getByText('PETG');
expect(badge).toHaveClass('bg-blue-100');
expect(badge).toHaveClass('text-blue-800');
});
it('renders ABS badge with correct style', () => {
const { getByText } = render(<MaterialBadge base="ABS" />);
const badge = getByText('ABS');
expect(badge).toHaveClass('bg-red-100');
expect(badge).toHaveClass('text-red-800');
});
it('renders TPU badge with correct style', () => {
const { getByText } = render(<MaterialBadge base="TPU" />);
const badge = getByText('TPU');
expect(badge).toHaveClass('bg-purple-100');
expect(badge).toHaveClass('text-purple-800');
});
});
describe('Unknown material type', () => {
it('renders unknown material with default style', () => {
const { getByText } = render(<MaterialBadge base="UNKNOWN" />);
const badge = getByText('UNKNOWN');
expect(badge).toHaveClass('bg-gray-100');
expect(badge).toHaveClass('text-gray-800');
});
});
describe('With modifier', () => {
it('renders base and modifier', () => {
const { getByText } = render(<MaterialBadge base="PLA" modifier="Silk" />);
expect(getByText('PLA')).toBeInTheDocument();
expect(getByText('Silk')).toBeInTheDocument();
});
it('renders modifier with correct style', () => {
const { getByText } = render(<MaterialBadge base="PLA" modifier="Matte" />);
const modifier = getByText('Matte');
expect(modifier).toHaveClass('bg-gray-100');
expect(modifier).toHaveClass('text-gray-800');
});
});
describe('Common styles', () => {
it('has correct padding and shape', () => {
const { getByText } = render(<MaterialBadge base="PLA" />);
const badge = getByText('PLA');
expect(badge).toHaveClass('px-2.5');
expect(badge).toHaveClass('py-0.5');
expect(badge).toHaveClass('rounded-full');
});
it('has correct text size', () => {
const { getByText } = render(<MaterialBadge base="PLA" />);
const badge = getByText('PLA');
expect(badge).toHaveClass('text-xs');
});
it('has correct font weight', () => {
const { getByText } = render(<MaterialBadge base="PLA" />);
const badge = getByText('PLA');
expect(badge).toHaveClass('font-medium');
});
it('accepts custom className', () => {
const { container } = render(<MaterialBadge base="PLA" className="custom-class" />);
const wrapper = container.firstChild;
expect(wrapper).toHaveClass('custom-class');
});
});
});

View File

@@ -0,0 +1,100 @@
import { bambuLabColors, getFilamentColor, getColorStyle, ColorMapping } from '@/src/data/bambuLabColors';
describe('Bambu Lab Colors Data', () => {
describe('bambuLabColors', () => {
it('should have color definitions', () => {
expect(Object.keys(bambuLabColors).length).toBeGreaterThan(0);
});
it('should have correct structure for each color', () => {
Object.entries(bambuLabColors).forEach(([colorName, colorMapping]) => {
expect(colorMapping).toHaveProperty('hex');
expect(colorMapping.hex).toBeDefined();
});
});
it('should have known colors', () => {
expect(bambuLabColors).toHaveProperty('Black');
expect(bambuLabColors).toHaveProperty('White');
expect(bambuLabColors).toHaveProperty('Red');
expect(bambuLabColors).toHaveProperty('Blue');
expect(bambuLabColors).toHaveProperty('Green');
});
it('should have valid hex colors', () => {
Object.entries(bambuLabColors).forEach(([colorName, colorMapping]) => {
if (typeof colorMapping.hex === 'string') {
expect(colorMapping.hex).toMatch(/^#[0-9A-Fa-f]{6}$/);
} else if (Array.isArray(colorMapping.hex)) {
colorMapping.hex.forEach(hex => {
expect(hex).toMatch(/^#[0-9A-Fa-f]{6}$/);
});
}
});
});
it('should have Unknown fallback color', () => {
expect(bambuLabColors).toHaveProperty('Unknown');
expect(bambuLabColors.Unknown.hex).toBe('#CCCCCC');
});
});
describe('getFilamentColor', () => {
it('should return exact match', () => {
const result = getFilamentColor('Black');
expect(result).toEqual(bambuLabColors['Black']);
});
it('should return case-insensitive match', () => {
const result = getFilamentColor('black');
expect(result).toEqual(bambuLabColors['Black']);
});
it('should return partial match', () => {
const result = getFilamentColor('PLA Black');
expect(result).toEqual(bambuLabColors['Black']);
});
it('should return default color for non-existent color', () => {
const result = getFilamentColor('NonExistentColor');
expect(result).toBeDefined();
expect(result).toHaveProperty('hex');
});
it('should handle empty string', () => {
const result = getFilamentColor('');
expect(result).toBeDefined();
expect(result).toHaveProperty('hex');
});
});
describe('getColorStyle', () => {
it('should return backgroundColor for single hex color', () => {
const colorMapping: ColorMapping = { hex: '#FF0000' };
const result = getColorStyle(colorMapping);
expect(result).toEqual({ backgroundColor: '#FF0000' });
});
it('should return backgroundColor for hex array without gradient', () => {
const colorMapping: ColorMapping = { hex: ['#FF0000', '#00FF00'] };
const result = getColorStyle(colorMapping);
expect(result).toEqual({ backgroundColor: '#FF0000' });
});
it('should return gradient for isGradient true', () => {
const colorMapping: ColorMapping = { hex: ['#FF0000', '#00FF00'], isGradient: true };
const result = getColorStyle(colorMapping);
expect(result).toEqual({
background: 'linear-gradient(90deg, #FF0000 0%, #00FF00 100%)'
});
});
it('should handle gradient with more than 2 colors', () => {
const colorMapping: ColorMapping = { hex: ['#FF0000', '#00FF00', '#0000FF'], isGradient: true };
const result = getColorStyle(colorMapping);
expect(result).toEqual({
background: 'linear-gradient(90deg, #FF0000 0%, #00FF00 100%)'
});
});
});
});

View File

@@ -0,0 +1,68 @@
import { bambuLabColors, colorsByFinish, getColorHex, getColorsForFinish } from '@/src/data/bambuLabColorsComplete';
describe('Bambu Lab Colors Complete Data', () => {
it('should have color definitions', () => {
expect(bambuLabColors).toBeDefined();
expect(typeof bambuLabColors).toBe('object');
expect(Object.keys(bambuLabColors).length).toBeGreaterThan(0);
});
it('should have basic colors', () => {
expect(bambuLabColors).toHaveProperty('Black');
expect(bambuLabColors).toHaveProperty('White');
expect(bambuLabColors).toHaveProperty('Red');
expect(bambuLabColors).toHaveProperty('Blue');
});
it('should have matte colors', () => {
expect(bambuLabColors).toHaveProperty('Matte Black');
expect(bambuLabColors).toHaveProperty('Matte White');
expect(bambuLabColors).toHaveProperty('Matte Red');
});
it('should have silk colors', () => {
expect(bambuLabColors).toHaveProperty('Silk White');
expect(bambuLabColors).toHaveProperty('Silk Black');
expect(bambuLabColors).toHaveProperty('Silk Gold');
});
it('should have valid hex colors', () => {
Object.entries(bambuLabColors).forEach(([colorName, hex]) => {
expect(hex).toMatch(/^#[0-9A-Fa-f]{6}$/);
});
});
it('should have colorsByFinish defined', () => {
expect(colorsByFinish).toBeDefined();
expect(typeof colorsByFinish).toBe('object');
});
it('should have finish categories', () => {
expect(colorsByFinish).toHaveProperty('Basic');
expect(colorsByFinish).toHaveProperty('Matte');
expect(colorsByFinish).toHaveProperty('Silk');
expect(colorsByFinish).toHaveProperty('Metal');
expect(colorsByFinish).toHaveProperty('Sparkle');
expect(colorsByFinish).toHaveProperty('Glow');
expect(colorsByFinish).toHaveProperty('Transparent');
expect(colorsByFinish).toHaveProperty('Support');
});
it('should return valid hex for getColorHex', () => {
const hex = getColorHex('Black');
expect(hex).toBe('#000000');
const unknownHex = getColorHex('Unknown Color');
expect(unknownHex).toBe('#000000');
});
it('should return colors for finish', () => {
const basicColors = getColorsForFinish('Basic');
expect(Array.isArray(basicColors)).toBe(true);
expect(basicColors.length).toBeGreaterThan(0);
const unknownFinish = getColorsForFinish('Unknown');
expect(Array.isArray(unknownFinish)).toBe(true);
expect(unknownFinish.length).toBe(0);
});
});

Binary file not shown.

View File

@@ -154,16 +154,6 @@ export default function AdminDashboard() {
useEffect(() => {
fetchAllData();
// Refresh when window regains focus
const handleFocus = () => {
fetchAllData();
};
window.addEventListener('focus', handleFocus);
return () => {
window.removeEventListener('focus', handleFocus);
};
}, []);
// Sorting logic
@@ -721,7 +711,21 @@ function FilamentForm({
cena_refill: refillPrice || 3499,
cena_spulna: spulnaPrice || 3999,
});
}, [filament, availableColors]);
}, [filament]);
// Update prices when color selection changes
useEffect(() => {
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;

View File

@@ -32,7 +32,8 @@ INSERT INTO colors (name, hex) VALUES
('Silver', '#A6A9AA'),
('Blue Grey', '#5B6579'),
('Dark Gray', '#555555'),
('Black', '#000000')
('Black', '#000000'),
('Latte Brown', '#D3B7A7')
ON CONFLICT (name)
DO UPDATE SET hex = EXCLUDED.hex;
@@ -55,7 +56,7 @@ WHERE c.name IN (
'Mistletoe Green', 'Pink', 'Hot Pink', 'Magenta', 'Red',
'Maroon Red', 'Purple', 'Indigo Purple', 'Turquoise', 'Cyan',
'Cobalt Blue', 'Blue', 'Brown', 'Cocoa Brown', 'Bronze',
'Gray', 'Silver', 'Blue Grey', 'Dark Gray', 'Black'
'Gray', 'Silver', 'Blue Grey', 'Dark Gray', 'Black', 'Latte Brown'
)
AND NOT EXISTS (
SELECT 1 FROM filaments f
@@ -75,7 +76,7 @@ AND boja IN (
'Mistletoe Green', 'Pink', 'Hot Pink', 'Magenta', 'Red',
'Maroon Red', 'Purple', 'Indigo Purple', 'Turquoise', 'Cyan',
'Cobalt Blue', 'Blue', 'Brown', 'Cocoa Brown', 'Bronze',
'Gray', 'Silver', 'Blue Grey', 'Dark Gray', 'Black'
'Gray', 'Silver', 'Blue Grey', 'Dark Gray', 'Black', 'Latte Brown'
);
-- Zero out ALL other filaments (not PLA Basic with these specific colors)
@@ -90,6 +91,6 @@ WHERE NOT (
'Mistletoe Green', 'Pink', 'Hot Pink', 'Magenta', 'Red',
'Maroon Red', 'Purple', 'Indigo Purple', 'Turquoise', 'Cyan',
'Cobalt Blue', 'Blue', 'Brown', 'Cocoa Brown', 'Bronze',
'Gray', 'Silver', 'Blue Grey', 'Dark Gray', 'Black'
'Gray', 'Silver', 'Blue Grey', 'Dark Gray', 'Black', 'Latte Brown'
)
);

View File

@@ -1,255 +0,0 @@
# Improved Data Structure Proposal
## Current Issues
1. Mixed languages (English/Serbian)
2. String fields for numeric/boolean values
3. Inconsistent status representation
4. No proper inventory tracking
5. Missing important metadata
## Proposed Structure
```typescript
interface Filament {
// Identifiers
id: string;
sku?: string; // For internal tracking
// Product Info
brand: string;
type: 'PLA' | 'PETG' | 'ABS' | 'TPU' | 'SILK' | 'CF' | 'WOOD';
material: {
base: 'PLA' | 'PETG' | 'ABS' | 'TPU';
modifier?: 'Silk' | 'Matte' | 'Glow' | 'Wood' | 'CF';
};
color: {
name: string;
hex?: string; // Color code for UI display
pantone?: string; // For color matching
};
// Physical Properties
weight: {
value: number; // 1000 for 1kg, 500 for 0.5kg
unit: 'g' | 'kg';
};
diameter: number; // 1.75 or 2.85
// Inventory Status
inventory: {
total: number; // Total spools
available: number; // Available for use
inUse: number; // Currently being used
locations: {
vacuum: number; // In vacuum storage
opened: number; // Opened but usable
printer: number; // Loaded in printer
};
};
// Purchase Info
pricing: {
purchasePrice?: number;
currency: 'RSD' | 'EUR' | 'USD';
supplier?: string;
purchaseDate?: string;
};
// Condition
condition: {
isRefill: boolean;
openedDate?: string;
expiryDate?: string;
storageCondition: 'vacuum' | 'sealed' | 'opened' | 'desiccant';
humidity?: number; // Last measured
};
// Metadata
tags: string[]; // ['premium', 'engineering', 'easy-print']
notes?: string; // Special handling instructions
images?: string[]; // S3 URLs for photos
// Timestamps
createdAt: string;
updatedAt: string;
lastUsed?: string;
}
```
## Benefits
### 1. **Better Filtering**
```typescript
// Find all sealed PLA under 1kg
filaments.filter(f =>
f.material.base === 'PLA' &&
f.weight.value <= 1000 &&
f.condition.storageCondition === 'vacuum'
)
```
### 2. **Inventory Management**
```typescript
// Get total available filament weight
const totalWeight = filaments.reduce((sum, f) =>
sum + (f.inventory.available * f.weight.value), 0
);
// Find low stock items
const lowStock = filaments.filter(f =>
f.inventory.available <= 1 && f.inventory.total > 0
);
```
### 3. **Color Management**
```typescript
// Group by color for visualization
const colorGroups = filaments.reduce((groups, f) => {
const color = f.color.name;
groups[color] = groups[color] || [];
groups[color].push(f);
return groups;
}, {});
```
### 4. **Usage Tracking**
```typescript
// Find most used filaments
const mostUsed = filaments
.filter(f => f.lastUsed)
.sort((a, b) => new Date(b.lastUsed) - new Date(a.lastUsed))
.slice(0, 10);
```
## Migration Strategy
### Phase 1: Add New Fields (Non-breaking)
```javascript
// Update Lambda to handle both old and new structure
const migrateFilament = (old) => ({
...old,
material: {
base: old.tip || 'PLA',
modifier: old.finish !== 'Basic' ? old.finish : undefined
},
color: {
name: old.boja
},
weight: {
value: 1000, // Default 1kg
unit: 'g'
},
inventory: {
total: parseInt(old.kolicina) || 1,
available: old.otvoreno ? 0 : 1,
inUse: 0,
locations: {
vacuum: old.vakum ? 1 : 0,
opened: old.otvoreno ? 1 : 0,
printer: 0
}
},
condition: {
isRefill: old.refill === 'Da',
storageCondition: old.vakum ? 'vacuum' : (old.otvoreno ? 'opened' : 'sealed')
}
});
```
### Phase 2: Update UI Components
- Create new filter components for material type
- Add inventory status indicators
- Color preview badges
- Storage condition icons
### Phase 3: Enhanced Features
1. **Barcode/QR Integration**: Generate QR codes for each spool
2. **Usage History**: Track which prints used which filament
3. **Alerts**: Low stock, expiry warnings
4. **Analytics**: Cost per print, filament usage trends
## DynamoDB Optimization
### Current Indexes
- brand-index
- tip-index
- status-index
### Proposed Indexes
```terraform
global_secondary_index {
name = "material-color-index"
hash_key = "material.base"
range_key = "color.name"
}
global_secondary_index {
name = "inventory-status-index"
hash_key = "condition.storageCondition"
range_key = "inventory.available"
}
global_secondary_index {
name = "brand-type-index"
hash_key = "brand"
range_key = "material.base"
}
```
## Example Queries
### Find all available green filaments
```javascript
const greenFilaments = await dynamodb.query({
IndexName: 'material-color-index',
FilterExpression: 'contains(color.name, :green) AND inventory.available > :zero',
ExpressionAttributeValues: {
':green': 'Green',
':zero': 0
}
}).promise();
```
### Get inventory summary
```javascript
const summary = await dynamodb.scan({
TableName: TABLE_NAME,
ProjectionExpression: 'brand, material.base, inventory'
}).promise();
const report = summary.Items.reduce((acc, item) => {
const key = `${item.brand}-${item.material.base}`;
acc[key] = (acc[key] || 0) + item.inventory.total;
return acc;
}, {});
```
## UI Improvements
### 1. **Visual Inventory Status**
```tsx
<div className="flex gap-2">
{filament.inventory.locations.vacuum > 0 && (
<Badge icon="vacuum" count={filament.inventory.locations.vacuum} />
)}
{filament.inventory.locations.opened > 0 && (
<Badge icon="box-open" count={filament.inventory.locations.opened} />
)}
</div>
```
### 2. **Color Swatches**
```tsx
<div
className="w-8 h-8 rounded-full border-2"
style={{ backgroundColor: filament.color.hex || getColorFromName(filament.color.name) }}
title={filament.color.name}
/>
```
### 3. **Smart Filters**
- Quick filters: "Ready to use", "Low stock", "Refills only"
- Material groups: "Standard PLA", "Engineering", "Specialty"
- Storage status: "Vacuum sealed", "Open spools", "In printer"
Would you like me to implement this improved structure?

View File

@@ -3,4 +3,22 @@ import '@testing-library/jest-dom'
// Add TextEncoder/TextDecoder globals for Node.js environment
const { TextEncoder, TextDecoder } = require('util');
global.TextEncoder = TextEncoder;
global.TextDecoder = TextDecoder;
global.TextDecoder = TextDecoder;
// Mock axios globally
jest.mock('axios', () => ({
create: jest.fn(() => ({
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
delete: jest.fn(),
interceptors: {
request: {
use: jest.fn()
},
response: {
use: jest.fn()
}
}
}))
}))

View File

@@ -1,56 +0,0 @@
-- Add 1 refill and 1 spulna for each color as PLA Basic filaments
-- Run this with: psql $DATABASE_URL -f scripts/add-basic-refills.sql
-- First show what colors we have
SELECT name, hex FROM colors ORDER BY name;
-- Insert PLA Basic filaments with 1 refill and 1 spulna for each color that doesn't already have one
INSERT INTO filaments (tip, finish, boja, boja_hex, refill, spulna, kolicina, cena)
SELECT
'PLA' as tip,
'Basic' as finish,
c.name as boja,
c.hex as boja_hex,
1 as refill,
1 as spulna,
2 as kolicina, -- 1 refill + 1 spulna
'3999' as cena
FROM colors c
WHERE NOT EXISTS (
SELECT 1 FROM filaments f
WHERE f.tip = 'PLA'
AND f.finish = 'Basic'
AND f.boja = c.name
)
ON CONFLICT DO NOTHING;
-- Update any existing PLA Basic filaments to have 1 refill and 1 spulna
UPDATE filaments
SET refill = 1,
spulna = 1,
kolicina = 2 -- Update quantity to reflect 1 refill + 1 spulna
WHERE tip = 'PLA'
AND finish = 'Basic'
AND (refill = 0 OR spulna = 0);
-- Show summary
SELECT
'Total PLA Basic filaments with refills and spulna' as description,
COUNT(*) as count
FROM filaments
WHERE tip = 'PLA'
AND finish = 'Basic'
AND refill = 1
AND spulna = 1;
-- Show all PLA Basic filaments
SELECT
boja as color,
refill,
spulna,
kolicina as quantity,
cena as price
FROM filaments
WHERE tip = 'PLA'
AND finish = 'Basic'
ORDER BY boja;

View File

@@ -1,17 +0,0 @@
#!/bin/bash
echo "Running sale fields migration..."
# Get RDS endpoint from AWS
RDS_ENDPOINT=$(aws rds describe-db-instances --region eu-central-1 --db-instance-identifier filamenteka --query 'DBInstances[0].Endpoint.Address' --output text)
# Get database credentials from Secrets Manager
DB_CREDS=$(aws secretsmanager get-secret-value --region eu-central-1 --secret-id filamenteka-db-credentials --query 'SecretString' --output text)
DB_USER=$(echo $DB_CREDS | jq -r '.username')
DB_PASS=$(echo $DB_CREDS | jq -r '.password')
DB_NAME=$(echo $DB_CREDS | jq -r '.database')
# Run the migration
PGPASSWORD="$DB_PASS" psql -h $RDS_ENDPOINT -U $DB_USER -d $DB_NAME -f database/migrations/014_add_sale_fields.sql
echo "Migration completed!"