diff --git a/DEPLOY.md b/DEPLOY.md deleted file mode 100644 index eea6f03..0000000 --- a/DEPLOY.md +++ /dev/null @@ -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`) \ No newline at end of file diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md deleted file mode 100644 index c028ce4..0000000 --- a/DEPLOYMENT.md +++ /dev/null @@ -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 \ No newline at end of file diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md deleted file mode 100644 index df9273e..0000000 --- a/PROJECT_STRUCTURE.md +++ /dev/null @@ -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 \ No newline at end of file diff --git a/__tests__/api.test.ts b/__tests__/api.test.ts new file mode 100644 index 0000000..cc9b75f --- /dev/null +++ b/__tests__/api.test.ts @@ -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(); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/components/BackToTop.test.tsx b/__tests__/components/BackToTop.test.tsx new file mode 100644 index 0000000..791e1c4 --- /dev/null +++ b/__tests__/components/BackToTop.test.tsx @@ -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(); + const button = container.querySelector('button'); + expect(button).not.toBeInTheDocument(); + }); + + it('renders button when scrolled down', () => { + const { container } = render(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + unmount(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function)); + removeEventListenerSpy.mockRestore(); + }); +}); \ No newline at end of file diff --git a/__tests__/components/ColorCell.test.tsx b/__tests__/components/ColorCell.test.tsx new file mode 100644 index 0000000..a15f1e9 --- /dev/null +++ b/__tests__/components/ColorCell.test.tsx @@ -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(); + expect(getByText('Black')).toBeInTheDocument(); + }); + + it('renders color swatch with correct style', () => { + const { container } = render(); + const colorDiv = container.querySelector('.w-6.h-6'); + expect(colorDiv).toHaveStyle({ backgroundColor: '#FF0000' }); + }); + + it('renders with correct dimensions', () => { + const { container } = render(); + 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(); + 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(); + 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(); + const colorDiv = container.querySelector('.w-6.h-6'); + expect(colorDiv).toHaveAttribute('title', '#000000'); + }); + + it('has title attribute with gradient hex values', () => { + const { container } = render(); + const colorDiv = container.querySelector('.w-6.h-6'); + expect(colorDiv).toHaveAttribute('title', '#FF0000 - #00FF00'); + }); + + it('renders with flex layout', () => { + const { container } = render(); + const wrapper = container.firstChild; + expect(wrapper).toHaveClass('flex'); + expect(wrapper).toHaveClass('items-center'); + expect(wrapper).toHaveClass('gap-2'); + }); +}); \ No newline at end of file diff --git a/__tests__/components/MaterialBadge.test.tsx b/__tests__/components/MaterialBadge.test.tsx new file mode 100644 index 0000000..8ebed8e --- /dev/null +++ b/__tests__/components/MaterialBadge.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + expect(getByText('PLA')).toBeInTheDocument(); + expect(getByText('Silk')).toBeInTheDocument(); + }); + + it('renders modifier with correct style', () => { + const { getByText } = render(); + 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(); + 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(); + const badge = getByText('PLA'); + expect(badge).toHaveClass('text-xs'); + }); + + it('has correct font weight', () => { + const { getByText } = render(); + const badge = getByText('PLA'); + expect(badge).toHaveClass('font-medium'); + }); + + it('accepts custom className', () => { + const { container } = render(); + const wrapper = container.firstChild; + expect(wrapper).toHaveClass('custom-class'); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/data/bambuLabColors.test.ts b/__tests__/data/bambuLabColors.test.ts new file mode 100644 index 0000000..2e2590b --- /dev/null +++ b/__tests__/data/bambuLabColors.test.ts @@ -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%)' + }); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/data/bambuLabColorsComplete.test.ts b/__tests__/data/bambuLabColorsComplete.test.ts new file mode 100644 index 0000000..65fcc17 --- /dev/null +++ b/__tests__/data/bambuLabColorsComplete.test.ts @@ -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); + }); +}); \ No newline at end of file diff --git a/api-update-sale.tar.gz b/api-update-sale.tar.gz deleted file mode 100644 index b99fd73..0000000 Binary files a/api-update-sale.tar.gz and /dev/null differ diff --git a/app/upadaj/dashboard/page.tsx b/app/upadaj/dashboard/page.tsx index ac25628..0f40e8e 100644 --- a/app/upadaj/dashboard/page.tsx +++ b/app/upadaj/dashboard/page.tsx @@ -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) => { const { name, value } = e.target; diff --git a/database/migrations/013_add_pla_basic_colors.sql b/database/migrations/013_add_pla_basic_colors.sql index c61e319..6645ed5 100644 --- a/database/migrations/013_add_pla_basic_colors.sql +++ b/database/migrations/013_add_pla_basic_colors.sql @@ -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' ) ); \ No newline at end of file diff --git a/docs/DATA_STRUCTURE_PROPOSAL.md b/docs/DATA_STRUCTURE_PROPOSAL.md deleted file mode 100644 index 1256369..0000000 --- a/docs/DATA_STRUCTURE_PROPOSAL.md +++ /dev/null @@ -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 -
- {filament.inventory.locations.vacuum > 0 && ( - - )} - {filament.inventory.locations.opened > 0 && ( - - )} -
-``` - -### 2. **Color Swatches** -```tsx -
-``` - -### 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? \ No newline at end of file diff --git a/jest.setup.js b/jest.setup.js index 7823063..95e867d 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -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; \ No newline at end of file +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() + } + } + })) +})) \ No newline at end of file diff --git a/scripts/add-basic-refills.sql b/scripts/add-basic-refills.sql deleted file mode 100644 index cc3296d..0000000 --- a/scripts/add-basic-refills.sql +++ /dev/null @@ -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; \ No newline at end of file diff --git a/scripts/run-sale-migration.sh b/scripts/run-sale-migration.sh deleted file mode 100755 index d1e5bc2..0000000 --- a/scripts/run-sale-migration.sh +++ /dev/null @@ -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!" \ No newline at end of file