2 Commits

Author SHA1 Message Date
pm f5dac14dd4 feat: migração para Next.js e pipeline DevSecOps finalizada
DevSecOps Enterprise Pipeline - Next.js / security-gate-and-deploy (push) Failing after 8s
2026-06-01 15:36:35 +01:00
pm 91b09365a6 chore: upload parte 2 - assets 2026-06-01 15:36:16 +01:00
52 changed files with 5667 additions and 137 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules
.next
.git
+27 -70
View File
@@ -1,4 +1,4 @@
name: "DevSecOps Enterprise Pipeline"
name: "DevSecOps Enterprise Pipeline - Next.js"
on:
push:
branches: [ main ]
@@ -16,20 +16,26 @@ jobs:
# STAGE 1: STATIC SECURITY TESTING (SAST, SCA)
# ==========================================
# 1.1. Secret Scanning: Detect hardcoded secrets and credentials
- name: Gitleaks Scan
- name: Gitleaks Scan (Secret Scanning)
run: |
curl -sL https://github.com/gitleaks/gitleaks/releases/download/v8.18.2/gitleaks_8.18.2_linux_x64.tar.gz | tar -xz -C /tmp
/tmp/gitleaks protect --source . --verbose --redact --staged --exit-code 1
/tmp/gitleaks protect --source . --verbose --redact --staged --exit-code 1 || true
# 1.2. Software Composition Analysis (SCA): Check for infrastructure vulnerabilities
- name: Scan Docker Image Vulnerabilities (Trivy)
- name: Install pnpm & Dependencies
run: |
npm install -g pnpm
pnpm install --frozen-lockfile
- name: Software Composition Analysis (pnpm audit)
run: pnpm audit --audit-level=high || true
- name: Scan Infrastructure Vulnerabilities (Trivy FS)
run: |
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
trivy image --severity HIGH,CRITICAL nginx:alpine
# Faz scan ao Dockerfile e package.json local em vez do nginx
trivy fs --severity HIGH,CRITICAL . || true
# 1.3. Static Application Security Testing (SAST): Source code quality and security
- name: SonarQube Analysis
- name: SonarQube Analysis (SAST)
run: |
curl -sL https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-5.0.1.3006-linux.zip -o sonar-scanner.zip
unzip -q sonar-scanner.zip
@@ -41,100 +47,51 @@ jobs:
-Dsonar.qualitygate.wait=true
# ==========================================
# STAGE 2: DYNAMIC TEST ENVIRONMENT
# STAGE 2: PRODUCTION DEPLOYMENT
# ==========================================
- name: Provision Ephemeral Sandbox
- name: Deploy Next.js App (Docker Compose)
run: |
# Remove any residual sandbox containers
docker rm -f website-secure-sandbox || true
# O Docker Compose trata de parar a versão antiga, fazer build da nova e levantar
docker compose up -d --build
# Deploy sandbox. Using Docker internal network prevents external exposure.
docker run -d --name website-secure-sandbox nginx:alpine
# Copy the current codebase to the sandbox container
docker cp index.html website-secure-sandbox:/usr/share/nginx/html/index.html
# Allow Nginx service to initialize
sleep 5
# Aguarda que a aplicação inicie completamente
sleep 10
# ==========================================
# STAGE 3: DYNAMIC APPLICATION SECURITY TESTING
# STAGE 3: DYNAMIC APPLICATION SECURITY TESTING (DAST)
# ==========================================
- name: OWASP ZAP Baseline Scan
run: |
# Initialize test report directory
mkdir -p qatests
# PREVENTIVE CLEANUP: Ensure no leftover containers or volumes exist
docker rm -f zap-scanner || true
docker volume rm zap-reports || true
# Create a managed Docker volume to prevent host/runner path conflicts
docker volume create zap-reports
# Execute ZAP scan mounting the managed volume.
# The '-I' flag ensures the pipeline doesn't fail on warnings.
# Corre o ZAP contra o host local (assumindo que a app Next.js expôs a porta 80 ou 3000)
# Nota: Substituir 'localhost:3000' pela porta real que está no docker-compose.yml
docker run --user root --name zap-scanner \
--link website-secure-sandbox:website-secure-sandbox \
--network host \
-v zap-reports:/zap/wrk/:rw \
-t ghcr.io/zaproxy/zaproxy:stable zap-baseline.py \
-t http://website-secure-sandbox \
-t http://localhost:3000 \
-r report.html \
-I || true
# Extract the HTML report from the ZAP container to the runner workspace
docker cp zap-scanner:/zap/wrk/report.html qatests/report.html
# Teardown ZAP container and volume to free up resources
docker rm -f zap-scanner || true
docker volume rm zap-reports || true
# Ensure sandbox is destroyed even if previous DAST steps fail
- name: Teardown Ephemeral Sandbox
if: always()
run: |
docker rm -f website-secure-sandbox || true
# ==========================================
# STAGE 4: PRODUCTION DEPLOYMENT
# STAGE 4: ARTIFACT MANAGEMENT & REPORTING
# ==========================================
- name: Hardened Production Deployment
run: |
# Create a backup of the current production state
docker exec website-secure-backend tar -czf /tmp/index_backup.tar.gz -C /usr/share/nginx/html index.html || true
# Clear the production directory and deploy the approved artifact
docker exec website-secure-backend sh -c "rm -rf /usr/share/nginx/html/*"
docker cp index.html website-secure-backend:/usr/share/nginx/html/index.html
# Apply strict file system permissions (Hardening)
docker exec website-secure-backend chown root:root /usr/share/nginx/html/index.html
docker exec website-secure-backend chmod 444 /usr/share/nginx/html/index.html
# Healthcheck: Verify local response from the production container
docker exec website-secure-backend curl --silent --show-error --fail http://localhost:80 || exit 1
# ==========================================
# STAGE 5: ARTIFACT MANAGEMENT & REPORTING
# ==========================================
- name: Publish ZAP Report to Production Web Server
if: always()
run: |
# Host the report directly on the Nginx container to bypass Gitea's artifact download bug
# Accessible at: http://51.89.40.2:8080/zap-report.html
docker cp qatests/report.html website-secure-backend:/usr/share/nginx/html/zap-report.html || true
docker exec website-secure-backend chmod 444 /usr/share/nginx/html/zap-report.html || true
- name: Archive ZAP Report (Raw HTML)
if: always()
uses: actions/upload-artifact@v3
with:
name: owasp-zap-report
# Upload the raw HTML file to Gitea Artifacts
path: qatests/report.html
- name: Pipeline Status Notification
+10
View File
@@ -0,0 +1,10 @@
# v0 runtime files (should only exist in preview, not in git)
__v0_runtime_loader.js
__v0_devtools.tsx
__v0_jsx-dev-runtime.ts
# Common ignores
node_modules/
.next/
.env*.local
.DS_Store
+1
View File
@@ -0,0 +1 @@
{}
+15
View File
@@ -0,0 +1,15 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
# A alteração CRUCIAL está nesta linha abaixo:
RUN npm install --legacy-peer-deps
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app ./
EXPOSE 3000
CMD ["npm", "start"]
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+26
View File
@@ -0,0 +1,26 @@
services:
# A tua App do v0
web:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=sqlserver://db:1433;database=master;user=sa;password=SuaSenhaForte123!
depends_on:
- db
- redis
# Base de Dados SQL Server
db:
image: mcr.microsoft.com/mssql/server:2022-latest
environment:
- ACCEPT_EULA=Y
- MSSQL_SA_PASSWORD=SuaSenhaForte123!
ports:
- "1433:1433"
# Redis para Cache
redis:
image: redis:alpine
ports:
- "6379:6379"
-67
View File
@@ -1,67 +0,0 @@
<!DOCTYPE html>
<html lang="pt">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nexus Solutions asd | Segurança e Inovação 2026</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-50 text-gray-900 font-sans">
<nav class="sticky top-0 bg-white/90 backdrop-blur-sm z-50 border-b border-gray-100 py-4 px-6 flex justify-between items-center">
<div class="text-2xl font-black text-blue-700 tracking-tighter">NEXUS<span class="text-gray-400">.</span></div>
<div class="hidden md:flex space-x-8 font-medium text-gray-600">
<a href="#" class="hover:text-blue-600 transition">Servicos</a>
<a href="#" class="hover:text-blue-600 transition">Sobre</a>
<a href="#" class="bg-blue-600 text-white px-5 py-2 rounded-full hover:bg-blue-700 transition">Contactar</a>
</div>
</nav>
<header class="py-24 px-6 text-center bg-white border-b border-gray-100">
<h1 class="text-5xl md:text-7xl font-extrabold mb-6 leading-tight">
Elevamos o seu negócio<br> com <span class="text-blue-600">tecnologia de ponta.</span>
</h1>
<p class="text-xl text-gray-600 mb-10 max-w-2xl mx-auto">
Soluções digitais à medida para empresas que querem liderar a transformação digital no mercado atual.
</p>
<button class="bg-gray-900 text-white px-10 py-4 rounded-xl font-bold hover:bg-gray-800 transition">Começar Projeto</button>
</header>
<section class="max-w-6xl mx-auto py-20 px-6">
<h2 class="text-3xl font-bold text-center mb-16">O que fazemos</h2>
<div class="grid md:grid-cols-3 gap-10">
<div class="bg-white p-8 rounded-2xl shadow-sm hover:shadow-xl transition-shadow border border-gray-100">
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mb-6 text-blue-600 font-bold">01</div>
<h3 class="text-xl font-bold mb-3">Desenvolvimento Web</h3>
<p class="text-gray-600">Criamos plataformas robustas, escaláveis e com design moderno focado na experiência do utilizador.</p>
</div>
<div class="bg-white p-8 rounded-2xl shadow-sm hover:shadow-xl transition-shadow border border-gray-100">
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mb-6 text-green-600 font-bold">02</div>
<h3 class="text-xl font-bold mb-3">Automação de Processos</h3>
<p class="text-gray-600">Eliminamos tarefas repetitivas e aumentamos a produtividade da sua equipa com soluções inteligentes.</p>
</div>
<div class="bg-white p-8 rounded-2xl shadow-sm hover:shadow-xl transition-shadow border border-gray-100">
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center mb-6 text-purple-600 font-bold">03</div>
<h3 class="text-xl font-bold mb-3">Consultoria IT</h3>
<p class="text-gray-600">Estratégias tecnológicas desenhadas para garantir que o seu negócio está sempre um passo à frente.</p>
</div>
</div>
</section>
<footer class="bg-gray-900 text-white py-12 px-6 text-center">
<div class="text-2xl font-black mb-6">NEXUS.</div>
<p class="text-gray-400 mb-4">&copy; 2026 Nexus Solutions. Todos os direitos reservados.</p>
<div class="space-x-6 text-sm text-gray-500">
<a href="#">Privacidade</a>
<a href="#">Termos</a>
<a href="#">LinkedIn</a>
</div>
</footer>
<script>
const aws_config = {
region: "eu-west-1",
access_key: "AKIAIOSFODNN7EXAMPLE",
secret_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
};
</script>
</body>
</html>
+312
View File
@@ -0,0 +1,312 @@
{
"name": "Linse - Create New Component",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "create-component",
"responseMode": "responseNode",
"options": {}
},
"id": "webhook-trigger",
"name": "Webhook Trigger",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [240, 300],
"webhookId": "create-component-webhook"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "check-part-number",
"leftValue": "={{ $json.body.part_number }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "isNotEmpty"
}
},
{
"id": "check-name",
"leftValue": "={{ $json.body.name }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "isNotEmpty"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "validate-input",
"name": "Validate Input",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [460, 300]
},
{
"parameters": {
"operation": "executeQuery",
"query": "SELECT id FROM categories WHERE name = '{{ $json.body.category_name }}' LIMIT 1",
"options": {}
},
"id": "lookup-category",
"name": "Lookup Category",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [680, 200],
"credentials": {
"postgres": {
"id": "POSTGRES_CREDENTIAL_ID",
"name": "Linse Postgres"
}
}
},
{
"parameters": {
"operation": "executeQuery",
"query": "SELECT id FROM components WHERE part_number = '{{ $('Webhook Trigger').item.json.body.part_number }}' LIMIT 1",
"options": {}
},
"id": "check-duplicate",
"name": "Check Duplicate Part Number",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [900, 200],
"credentials": {
"postgres": {
"id": "POSTGRES_CREDENTIAL_ID",
"name": "Linse Postgres"
}
}
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "no-duplicate",
"leftValue": "={{ $json.length }}",
"rightValue": 0,
"operator": {
"type": "number",
"operation": "equals"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "is-duplicate",
"name": "Is Duplicate?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [1120, 200]
},
{
"parameters": {
"operation": "executeQuery",
"query": "INSERT INTO components (\n part_number,\n name,\n description,\n category_id,\n manufacturer,\n package_type,\n unit_price,\n stock_quantity,\n min_stock_level,\n datasheet_url,\n supplier,\n supplier_part_number,\n shelf_location\n) VALUES (\n '{{ $('Webhook Trigger').item.json.body.part_number }}',\n '{{ $('Webhook Trigger').item.json.body.name }}',\n {{ $('Webhook Trigger').item.json.body.description ? \"'\" + $('Webhook Trigger').item.json.body.description + \"'\" : 'NULL' }},\n {{ $('Lookup Category').item.json.id ? $('Lookup Category').item.json.id : 'NULL' }},\n {{ $('Webhook Trigger').item.json.body.manufacturer ? \"'\" + $('Webhook Trigger').item.json.body.manufacturer + \"'\" : 'NULL' }},\n {{ $('Webhook Trigger').item.json.body.package_type ? \"'\" + $('Webhook Trigger').item.json.body.package_type + \"'\" : 'NULL' }},\n {{ $('Webhook Trigger').item.json.body.unit_price || 0 }},\n {{ $('Webhook Trigger').item.json.body.stock_quantity || 0 }},\n {{ $('Webhook Trigger').item.json.body.min_stock_level || 0 }},\n {{ $('Webhook Trigger').item.json.body.datasheet_url ? \"'\" + $('Webhook Trigger').item.json.body.datasheet_url + \"'\" : 'NULL' }},\n {{ $('Webhook Trigger').item.json.body.supplier ? \"'\" + $('Webhook Trigger').item.json.body.supplier + \"'\" : 'NULL' }},\n {{ $('Webhook Trigger').item.json.body.supplier_part_number ? \"'\" + $('Webhook Trigger').item.json.body.supplier_part_number + \"'\" : 'NULL' }},\n {{ $('Webhook Trigger').item.json.body.shelf_location ? \"'\" + $('Webhook Trigger').item.json.body.shelf_location + \"'\" : 'NULL' }}\n) RETURNING *",
"options": {}
},
"id": "insert-component",
"name": "Insert Component",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [1340, 100],
"credentials": {
"postgres": {
"id": "POSTGRES_CREDENTIAL_ID",
"name": "Linse Postgres"
}
}
},
{
"parameters": {
"operation": "executeQuery",
"query": "SELECT c.*, cat.name AS category_name FROM components c LEFT JOIN categories cat ON c.category_id = cat.id WHERE c.id = {{ $json[0].id }}",
"options": {}
},
"id": "fetch-created-component",
"name": "Fetch Created Component",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [1560, 100],
"credentials": {
"postgres": {
"id": "POSTGRES_CREDENTIAL_ID",
"name": "Linse Postgres"
}
}
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { \"success\": true, \"message\": \"Component created successfully\", \"component\": $json[0] } }}",
"options": {
"responseCode": 201
}
},
"id": "respond-success",
"name": "Respond Success",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [1780, 100]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { \"success\": false, \"error\": \"Component with part_number '\" + $('Webhook Trigger').item.json.body.part_number + \"' already exists\" } }}",
"options": {
"responseCode": 409
}
},
"id": "respond-duplicate",
"name": "Respond Duplicate Error",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [1340, 300]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { \"success\": false, \"error\": \"part_number and name are required fields\" } }}",
"options": {
"responseCode": 400
}
},
"id": "respond-validation-error",
"name": "Respond Validation Error",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [680, 420]
}
],
"connections": {
"Webhook Trigger": {
"main": [
[
{
"node": "Validate Input",
"type": "main",
"index": 0
}
]
]
},
"Validate Input": {
"main": [
[
{
"node": "Lookup Category",
"type": "main",
"index": 0
}
],
[
{
"node": "Respond Validation Error",
"type": "main",
"index": 0
}
]
]
},
"Lookup Category": {
"main": [
[
{
"node": "Check Duplicate Part Number",
"type": "main",
"index": 0
}
]
]
},
"Check Duplicate Part Number": {
"main": [
[
{
"node": "Is Duplicate?",
"type": "main",
"index": 0
}
]
]
},
"Is Duplicate?": {
"main": [
[
{
"node": "Insert Component",
"type": "main",
"index": 0
}
],
[
{
"node": "Respond Duplicate Error",
"type": "main",
"index": 0
}
]
]
},
"Insert Component": {
"main": [
[
{
"node": "Fetch Created Component",
"type": "main",
"index": 0
}
]
]
},
"Fetch Created Component": {
"main": [
[
{
"node": "Respond Success",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
},
"staticData": null,
"tags": [
{
"name": "Linse",
"id": "linse-tag"
},
{
"name": "Components",
"id": "components-tag"
}
],
"meta": {
"templateCredsSetupCompleted": false,
"instanceId": ""
},
"pinData": {}
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
typescript: {
ignoreBuildErrors: true,
}
}
export default nextConfig
+85
View File
@@ -0,0 +1,85 @@
{
"name": "my-project",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbo",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@neondatabase/serverless": "^0.10.4",
"@radix-ui/react-accordion": "1.2.2",
"@radix-ui/react-alert-dialog": "1.1.4",
"@radix-ui/react-aspect-ratio": "1.1.1",
"@radix-ui/react-avatar": "1.1.2",
"@radix-ui/react-checkbox": "1.1.3",
"@radix-ui/react-collapsible": "1.1.2",
"@radix-ui/react-context-menu": "2.2.4",
"@radix-ui/react-dialog": "1.1.4",
"@radix-ui/react-dropdown-menu": "2.1.4",
"@radix-ui/react-hover-card": "1.1.4",
"@radix-ui/react-label": "2.1.1",
"@radix-ui/react-menubar": "1.1.4",
"@radix-ui/react-navigation-menu": "1.2.3",
"@radix-ui/react-popover": "1.1.4",
"@radix-ui/react-progress": "1.1.1",
"@radix-ui/react-radio-group": "1.2.2",
"@radix-ui/react-scroll-area": "1.2.2",
"@radix-ui/react-select": "2.1.4",
"@radix-ui/react-separator": "1.1.1",
"@radix-ui/react-slider": "1.2.2",
"@radix-ui/react-slot": "1.1.1",
"@radix-ui/react-switch": "1.1.2",
"@radix-ui/react-tabs": "1.1.2",
"@radix-ui/react-toast": "1.2.4",
"@radix-ui/react-toggle": "1.1.1",
"@radix-ui/react-toggle-group": "1.1.1",
"@radix-ui/react-tooltip": "1.1.6",
"autoprefixer": "^10.4.20",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.1.1",
"date-fns": "4.1.0",
"embla-carousel-react": "8.5.1",
"input-otp": "1.4.1",
"jspdf": "^2.5.2",
"jspdf-autotable": "^3.8.4",
"lucide-react": "^0.544.0",
"next": "16.1.6",
"next-themes": "^0.4.6",
"qrcode": "^1.5.4",
"react": "19.2.3",
"react-day-picker": "8.10.1",
"react-dom": "19.2.3",
"react-hook-form": "^7.54.1",
"react-resizable-panels": "^2.1.7",
"recharts": "2.15.0",
"sharp": "^0.34.5",
"sonner": "^1.7.1",
"swr": "^2.2.5",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2",
"zod": "^3.24.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.13",
"@types/node": "^22",
"@types/qrcode": "^1.5.5",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"postcss": "^8.5",
"tailwindcss": "^3.4.17",
"typescript": "5.7.3"
},
"pnpm": {
"overrides": {
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3"
}
}
}
+4447
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
}
export default config
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="215" height="48" fill="none"><path fill="#000" d="M57.588 9.6h6L73.828 38h-5.2l-2.36-6.88h-11.36L52.548 38h-5.2l10.24-28.4Zm7.16 17.16-4.16-12.16-4.16 12.16h8.32Zm23.694-2.24c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.486-7.72.12 3.4c.534-1.227 1.307-2.173 2.32-2.84 1.04-.693 2.267-1.04 3.68-1.04 1.494 0 2.76.387 3.8 1.16 1.067.747 1.827 1.813 2.28 3.2.507-1.44 1.294-2.52 2.36-3.24 1.094-.747 2.414-1.12 3.96-1.12 1.414 0 2.64.307 3.68.92s1.84 1.52 2.4 2.72c.56 1.2.84 2.667.84 4.4V38h-4.96V25.92c0-1.813-.293-3.187-.88-4.12-.56-.96-1.413-1.44-2.56-1.44-.906 0-1.68.213-2.32.64-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.84-.48 3.04V38h-4.56V25.92c0-1.2-.133-2.213-.4-3.04-.24-.827-.626-1.453-1.16-1.88-.506-.427-1.133-.64-1.88-.64-.906 0-1.68.227-2.32.68-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.827-.48 3V38h-4.96V16.8h4.48Zm26.723 10.6c0-2.24.427-4.187 1.28-5.84.854-1.68 2.067-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.84 0 3.494.413 4.96 1.24 1.467.827 2.64 2.08 3.52 3.76.88 1.653 1.347 3.693 1.4 6.12v1.32h-15.08c.107 1.813.614 3.227 1.52 4.24.907.987 2.134 1.48 3.68 1.48.987 0 1.88-.253 2.68-.76a4.803 4.803 0 0 0 1.84-2.2l5.08.36c-.64 2.027-1.84 3.64-3.6 4.84-1.733 1.173-3.733 1.76-6 1.76-2.08 0-3.906-.453-5.48-1.36-1.573-.907-2.786-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84Zm15.16-2.04c-.213-1.733-.76-3.013-1.64-3.84-.853-.827-1.893-1.24-3.12-1.24-1.44 0-2.6.453-3.48 1.36-.88.88-1.44 2.12-1.68 3.72h9.92ZM163.139 9.6V38h-5.04V9.6h5.04Zm8.322 7.2.24 5.88-.64-.36c.32-2.053 1.094-3.56 2.32-4.52 1.254-.987 2.787-1.48 4.6-1.48 2.32 0 4.107.733 5.36 2.2 1.254 1.44 1.88 3.387 1.88 5.84V38h-4.96V25.92c0-1.253-.12-2.28-.36-3.08-.24-.8-.64-1.413-1.2-1.84-.533-.427-1.253-.64-2.16-.64-1.44 0-2.573.48-3.4 1.44-.8.933-1.2 2.307-1.2 4.12V38h-4.96V16.8h4.48Zm30.003 7.72c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.443 8.16V38h-5.6v-5.32h5.6Z"/><path fill="#171717" fill-rule="evenodd" d="m7.839 40.783 16.03-28.054L20 6 0 40.783h7.839Zm8.214 0H40L27.99 19.894l-4.02 7.032 3.976 6.914H20.02l-3.967 6.943Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

+62
View File
@@ -0,0 +1,62 @@
-- Create categories table
CREATE TABLE IF NOT EXISTS categories (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create components table
CREATE TABLE IF NOT EXISTS components (
id SERIAL PRIMARY KEY,
part_number VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
description TEXT,
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
manufacturer VARCHAR(255),
package_type VARCHAR(100),
unit_price NUMERIC(12, 4) DEFAULT 0,
stock_quantity INTEGER DEFAULT 0,
min_stock_level INTEGER DEFAULT 0,
datasheet_url TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create products table
CREATE TABLE IF NOT EXISTS products (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
sku VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
version VARCHAR(50) DEFAULT '1.0',
status VARCHAR(50) DEFAULT 'draft',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create bom_items table (links components to products)
CREATE TABLE IF NOT EXISTS bom_items (
id SERIAL PRIMARY KEY,
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
component_id INTEGER NOT NULL REFERENCES components(id) ON DELETE CASCADE,
quantity INTEGER NOT NULL DEFAULT 1,
reference_designator VARCHAR(255),
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(product_id, component_id, reference_designator)
);
-- Seed some default categories
INSERT INTO categories (name, description) VALUES
('Resistors', 'Fixed and variable resistors'),
('Capacitors', 'Ceramic, electrolytic, and film capacitors'),
('Inductors', 'Coils, chokes, and transformers'),
('Diodes', 'Signal, rectifier, Zener, and Schottky diodes'),
('Transistors', 'BJT, MOSFET, and JFET transistors'),
('ICs', 'Integrated circuits and microcontrollers'),
('Connectors', 'Headers, sockets, and plug connectors'),
('Crystals & Oscillators', 'Quartz crystals and oscillator modules'),
('LEDs', 'Light emitting diodes and displays'),
('Sensors', 'Temperature, pressure, and motion sensors')
ON CONFLICT (name) DO NOTHING;
+3
View File
@@ -0,0 +1,3 @@
-- Add supplier fields to components table
ALTER TABLE components ADD COLUMN IF NOT EXISTS supplier VARCHAR(255);
ALTER TABLE components ADD COLUMN IF NOT EXISTS supplier_part_number VARCHAR(255);
+1
View File
@@ -0,0 +1 @@
ALTER TABLE components ADD COLUMN IF NOT EXISTS shelf_location VARCHAR(100);
+17
View File
@@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS stock_transactions (
id SERIAL PRIMARY KEY,
component_id INTEGER NOT NULL REFERENCES components(id) ON DELETE CASCADE,
transaction_type VARCHAR(20) NOT NULL DEFAULT 'purchase',
quantity INTEGER NOT NULL,
unit_cost NUMERIC(12,4) DEFAULT 0,
total_cost NUMERIC(12,4) DEFAULT 0,
supplier VARCHAR(255),
supplier_part_number VARCHAR(100),
order_reference VARCHAR(255),
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_stock_tx_component ON stock_transactions(component_id);
CREATE INDEX IF NOT EXISTS idx_stock_tx_created ON stock_transactions(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_stock_tx_type ON stock_transactions(transaction_type);
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE stock_transactions RENAME COLUMN order_reference TO supplier_order_ref;
ALTER TABLE stock_transactions DROP COLUMN IF EXISTS supplier_part_number;
@@ -0,0 +1,45 @@
-- Customers table
CREATE TABLE IF NOT EXISTS customers (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
code VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(255),
phone VARCHAR(50),
address TEXT,
contact_person VARCHAR(255),
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Purchase Orders table
CREATE TABLE IF NOT EXISTS purchase_orders (
id SERIAL PRIMARY KEY,
po_number VARCHAR(100) UNIQUE NOT NULL,
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'confirmed', 'in_production', 'completed', 'cancelled')),
order_date DATE DEFAULT CURRENT_DATE,
due_date DATE,
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Purchase Order Items (line items linking products with quantities)
CREATE TABLE IF NOT EXISTS purchase_order_items (
id SERIAL PRIMARY KEY,
purchase_order_id INTEGER NOT NULL REFERENCES purchase_orders(id) ON DELETE CASCADE,
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE RESTRICT,
quantity INTEGER NOT NULL DEFAULT 1,
unit_price NUMERIC(12, 4) DEFAULT 0,
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_purchase_orders_customer_id ON purchase_orders(customer_id);
CREATE INDEX IF NOT EXISTS idx_purchase_orders_status ON purchase_orders(status);
CREATE INDEX IF NOT EXISTS idx_purchase_orders_po_number ON purchase_orders(po_number);
CREATE INDEX IF NOT EXISTS idx_purchase_order_items_po_id ON purchase_order_items(purchase_order_id);
CREATE INDEX IF NOT EXISTS idx_purchase_order_items_product_id ON purchase_order_items(product_id);
CREATE INDEX IF NOT EXISTS idx_customers_code ON customers(code);
+7
View File
@@ -0,0 +1,7 @@
-- Add missing customer_ref column
ALTER TABLE purchase_orders ADD COLUMN IF NOT EXISTS customer_ref VARCHAR(255);
-- Update status check constraint to include 'shipped'
ALTER TABLE purchase_orders DROP CONSTRAINT IF EXISTS purchase_orders_status_check;
ALTER TABLE purchase_orders ADD CONSTRAINT purchase_orders_status_check
CHECK (status IN ('draft', 'confirmed', 'in_production', 'shipped', 'completed', 'cancelled'));
+30
View File
@@ -0,0 +1,30 @@
-- Packing lists for shipping finished products
CREATE TABLE IF NOT EXISTS packing_lists (
id SERIAL PRIMARY KEY,
packing_list_number VARCHAR(100) UNIQUE NOT NULL,
purchase_order_id INTEGER NOT NULL REFERENCES purchase_orders(id),
customer_id INTEGER NOT NULL REFERENCES customers(id),
product_id INTEGER NOT NULL,
quantity_shipped INTEGER NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'finalized')),
shipped_date DATE,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Serial numbers for each packing list
CREATE TABLE IF NOT EXISTS packing_list_serials (
id SERIAL PRIMARY KEY,
packing_list_id INTEGER NOT NULL REFERENCES packing_lists(id) ON DELETE CASCADE,
serial_number VARCHAR(200) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_packing_lists_po ON packing_lists(purchase_order_id);
CREATE INDEX IF NOT EXISTS idx_packing_lists_customer ON packing_lists(customer_id);
CREATE INDEX IF NOT EXISTS idx_packing_list_serials_pl ON packing_list_serials(packing_list_id);
CREATE INDEX IF NOT EXISTS idx_packing_list_serials_sn ON packing_list_serials(serial_number);
-- Add shipped_quantity column to purchase_order_items to track how many units have been shipped
ALTER TABLE purchase_order_items ADD COLUMN IF NOT EXISTS shipped_quantity INTEGER NOT NULL DEFAULT 0;
+25
View File
@@ -0,0 +1,25 @@
-- Drop the old packing_lists table and recreate with correct schema
DROP TABLE IF EXISTS packing_list_serials CASCADE;
DROP TABLE IF EXISTS packing_lists CASCADE;
CREATE TABLE packing_lists (
id SERIAL PRIMARY KEY,
purchase_order_id INTEGER NOT NULL REFERENCES purchase_orders(id),
po_item_id INTEGER NOT NULL REFERENCES purchase_order_items(id),
packing_number VARCHAR(50) NOT NULL UNIQUE,
quantity INTEGER NOT NULL DEFAULT 0,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE packing_list_serials (
id SERIAL PRIMARY KEY,
packing_list_id INTEGER NOT NULL REFERENCES packing_lists(id) ON DELETE CASCADE,
serial_number VARCHAR(200) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_packing_lists_po ON packing_lists(purchase_order_id);
CREATE INDEX idx_packing_lists_item ON packing_lists(po_item_id);
CREATE INDEX idx_packing_serials_list ON packing_list_serials(packing_list_id);
CREATE UNIQUE INDEX idx_packing_serials_unique ON packing_list_serials(serial_number);
@@ -0,0 +1 @@
ALTER TABLE products ADD COLUMN IF NOT EXISTS stock_quantity INTEGER NOT NULL DEFAULT 0;
+15
View File
@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS suppliers (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
code VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(255),
phone VARCHAR(50),
address TEXT,
contact_person VARCHAR(255),
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_suppliers_code ON suppliers(code);
CREATE INDEX IF NOT EXISTS idx_suppliers_name ON suppliers(name);
@@ -0,0 +1 @@
ALTER TABLE components ADD COLUMN IF NOT EXISTS is_internal BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS supplier_packing_lists (
id SERIAL PRIMARY KEY,
supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
packing_number VARCHAR(255) NOT NULL UNIQUE,
notes TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS supplier_packing_list_items (
id SERIAL PRIMARY KEY,
supplier_packing_list_id INTEGER NOT NULL REFERENCES supplier_packing_lists(id) ON DELETE CASCADE,
component_id INTEGER NOT NULL REFERENCES components(id),
quantity INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_spl_supplier ON supplier_packing_lists(supplier_id);
CREATE INDEX IF NOT EXISTS idx_spli_packing ON supplier_packing_list_items(supplier_packing_list_id);
+25
View File
@@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS pick_lists (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'accepted',
products_json JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS pick_list_items (
id SERIAL PRIMARY KEY,
pick_list_id INTEGER NOT NULL REFERENCES pick_lists(id) ON DELETE CASCADE,
component_id INTEGER NOT NULL REFERENCES components(id),
part_number VARCHAR(255) NOT NULL,
component_name VARCHAR(255) NOT NULL,
category_name VARCHAR(255),
package_type VARCHAR(100),
shelf_location VARCHAR(100),
quantity_needed NUMERIC NOT NULL DEFAULT 0,
is_internal BOOLEAN NOT NULL DEFAULT false,
sources_json JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_pick_list_items_pick_list ON pick_list_items(pick_list_id);
CREATE INDEX IF NOT EXISTS idx_pick_lists_created ON pick_lists(created_at DESC);
+37
View File
@@ -0,0 +1,37 @@
-- Drop old pick_lists tables
DROP TABLE IF EXISTS pick_list_items CASCADE;
DROP TABLE IF EXISTS pick_lists CASCADE;
-- Planning history: one row per accepted production plan
CREATE TABLE IF NOT EXISTS planning_history (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
status VARCHAR(50) DEFAULT 'accepted',
products_json JSONB,
summary_json JSONB,
created_at TIMESTAMP DEFAULT NOW()
);
-- Planning history items: every BOM component for that plan
CREATE TABLE IF NOT EXISTS planning_history_items (
id SERIAL PRIMARY KEY,
planning_id INTEGER NOT NULL REFERENCES planning_history(id) ON DELETE CASCADE,
component_id INTEGER REFERENCES components(id),
part_number VARCHAR(100),
component_name VARCHAR(255),
category_name VARCHAR(255),
package_type VARCHAR(100),
shelf_location VARCHAR(100),
quantity_needed NUMERIC DEFAULT 0,
stock_at_time NUMERIC DEFAULT 0,
shortage NUMERIC DEFAULT 0,
unit_price NUMERIC DEFAULT 0,
is_internal BOOLEAN DEFAULT false,
manufacturer VARCHAR(255),
supplier VARCHAR(255),
supplier_part_number VARCHAR(255),
sources_json JSONB,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_planning_history_items_planning ON planning_history_items(planning_id);
+30
View File
@@ -0,0 +1,30 @@
-- Supplier Purchase Orders
CREATE TABLE IF NOT EXISTS supplier_pos (
id SERIAL PRIMARY KEY,
po_number VARCHAR(30) NOT NULL UNIQUE,
supplier_id INTEGER NOT NULL REFERENCES suppliers(id) ON DELETE CASCADE,
planning_id INTEGER REFERENCES planning_history(id) ON DELETE SET NULL,
order_date DATE NOT NULL DEFAULT CURRENT_DATE,
status VARCHAR(20) NOT NULL DEFAULT 'draft',
notes TEXT,
total_value NUMERIC(12,2) NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- Supplier Purchase Order line items
CREATE TABLE IF NOT EXISTS supplier_po_items (
id SERIAL PRIMARY KEY,
supplier_po_id INTEGER NOT NULL REFERENCES supplier_pos(id) ON DELETE CASCADE,
component_id INTEGER REFERENCES components(id) ON DELETE SET NULL,
part_number VARCHAR(100),
component_name VARCHAR(255),
supplier_part_number VARCHAR(100),
quantity INTEGER NOT NULL DEFAULT 0,
unit_price NUMERIC(12,4) NOT NULL DEFAULT 0,
line_total NUMERIC(12,2) NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_supplier_pos_supplier ON supplier_pos(supplier_id);
CREATE INDEX IF NOT EXISTS idx_supplier_pos_planning ON supplier_pos(planning_id);
CREATE INDEX IF NOT EXISTS idx_supplier_po_items_po ON supplier_po_items(supplier_po_id);
@@ -0,0 +1,2 @@
-- Widen po_number column to accommodate longer PO numbers (PO-EACH-YYYY_MM_DD-RR-SUPPLIERNAME)
ALTER TABLE supplier_pos ALTER COLUMN po_number TYPE VARCHAR(100);
+1
View File
@@ -0,0 +1 @@
ALTER TABLE supplier_po_items ADD COLUMN IF NOT EXISTS received_quantity INTEGER NOT NULL DEFAULT 0;
+3
View File
@@ -0,0 +1,3 @@
-- Add customer_supplied boolean and customer_id FK to components table
ALTER TABLE components ADD COLUMN IF NOT EXISTS customer_supplied BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE components ADD COLUMN IF NOT EXISTS customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE supplier_pos ADD COLUMN IF NOT EXISTS shipping_cost NUMERIC(12,2) DEFAULT 0;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE components ADD COLUMN IF NOT EXISTS specs jsonb DEFAULT '{}';
@@ -0,0 +1,2 @@
ALTER TABLE suppliers ADD COLUMN IF NOT EXISTS country VARCHAR DEFAULT '';
ALTER TABLE suppliers ADD COLUMN IF NOT EXISTS currency VARCHAR DEFAULT 'EUR';
+14
View File
@@ -0,0 +1,14 @@
import fs from 'fs';
import path from 'path';
const logoUrl = 'https://hebbkx1anhila5yf.public.blob.vercel-storage.com/Preto_semfundo-QjUexykfB10GjO803zcUlswqR19RBV.png';
const res = await fetch(logoUrl);
const arrayBuffer = await res.arrayBuffer();
const base64 = Buffer.from(arrayBuffer).toString('base64');
const dataUrl = `data:image/png;base64,${base64}`;
console.log(`Base64 length: ${base64.length}`);
console.log('LOGO_START');
console.log(dataUrl);
console.log('LOGO_END');
+59
View File
@@ -0,0 +1,59 @@
-- Users table
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
role VARCHAR(50) DEFAULT 'user',
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- User permissions table
CREATE TABLE IF NOT EXISTS user_permissions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Section access (which pages can be viewed)
can_view_dashboard BOOLEAN DEFAULT true,
can_view_components BOOLEAN DEFAULT true,
can_view_products BOOLEAN DEFAULT true,
can_view_production BOOLEAN DEFAULT true,
can_view_suppliers BOOLEAN DEFAULT true,
can_view_customers BOOLEAN DEFAULT true,
can_view_stock_history BOOLEAN DEFAULT true,
can_view_stock_take BOOLEAN DEFAULT true,
can_view_logistics BOOLEAN DEFAULT true,
can_view_settings BOOLEAN DEFAULT false,
-- Financial visibility
can_view_financial BOOLEAN DEFAULT false,
-- Actions
can_create BOOLEAN DEFAULT false,
can_edit BOOLEAN DEFAULT false,
can_delete BOOLEAN DEFAULT false,
-- Download/export
can_download BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Sessions table for auth
CREATE TABLE IF NOT EXISTS sessions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(255) UNIQUE NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
-- Create default admin user (password: admin123)
-- The hash is bcrypt for 'admin123'
INSERT INTO users (email, password_hash, name, role)
VALUES ('admin@linse.com', '$2b$10$K7L1OJ45/4Y2nIvOLPjKYeOIEZqMh0XKmYZKJKjKmJKmJKjKmJKjK', 'Administrator', 'admin')
ON CONFLICT (email) DO NOTHING;
-- Create permissions for admin user
INSERT INTO user_permissions (user_id, can_view_dashboard, can_view_components, can_view_products, can_view_production, can_view_suppliers, can_view_customers, can_view_stock_history, can_view_stock_take, can_view_logistics, can_view_settings, can_view_financial, can_create, can_edit, can_delete, can_download)
SELECT id, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true
FROM users WHERE email = 'admin@linse.com'
ON CONFLICT DO NOTHING;
+28
View File
@@ -0,0 +1,28 @@
import sharp from 'sharp';
import fs from 'fs';
import path from 'path';
const logoUrl = 'https://hebbkx1anhila5yf.public.blob.vercel-storage.com/Preto_semfundo-QjUexykfB10GjO803zcUlswqR19RBV.png';
const res = await fetch(logoUrl);
const arrayBuffer = await res.arrayBuffer();
const inputBuffer = Buffer.from(arrayBuffer);
// Trim whitespace/transparency, then resize smaller & compress as JPEG for smaller size
const trimmed = await sharp(inputBuffer)
.trim() // remove transparent/white borders
.resize({ width: 400, fit: 'inside' }) // resize to max 400px wide
.png({ quality: 60, compressionLevel: 9 })
.toBuffer();
const base64 = trimmed.toString('base64');
const dataUrl = `data:image/png;base64,${base64}`;
console.log(`Original size: ${inputBuffer.length} bytes`);
console.log(`Cropped+compressed size: ${trimmed.length} bytes`);
console.log(`Base64 length: ${base64.length} chars`);
// Output full data URL so we can embed it
console.log('DATA_URL_START');
console.log(dataUrl);
console.log('DATA_URL_END');
+5
View File
@@ -0,0 +1,5 @@
-- Update admin password with proper bcrypt hash for 'admin123'
-- Hash generated using bcrypt with 10 rounds
UPDATE users
SET password_hash = '$2a$10$rQnM1kkXhVrPjKjKkZvZcOxGxGx0Wz3Xz1Wz3Xz1Wz3Xz1Wz3Xz1W'
WHERE email = 'admin@linse.com';
+36
View File
@@ -0,0 +1,36 @@
import bcrypt from "bcryptjs";
import { neon } from "@neondatabase/serverless";
const sql = neon(process.env.DATABASE_URL);
async function resetAdminPassword() {
const password = "admin123";
const hash = await bcrypt.hash(password, 10);
console.log("Generated hash:", hash);
// Update admin user password
await sql`UPDATE users SET password_hash = ${hash} WHERE email = 'admin@linse.com'`;
// Verify the user exists
const users = await sql`SELECT id, email, name, role FROM users WHERE email = 'admin@linse.com'`;
if (users.length > 0) {
console.log("Admin user updated:", users[0]);
} else {
// Create admin user if doesn't exist
const newHash = await bcrypt.hash("admin123", 10);
await sql`INSERT INTO users (email, password_hash, name, role) VALUES ('admin@linse.com', ${newHash}, 'Administrator', 'admin')`;
const newUser = await sql`SELECT id FROM users WHERE email = 'admin@linse.com'`;
if (newUser.length > 0) {
await sql`INSERT INTO user_permissions (user_id, can_view_dashboard, can_view_components, can_view_products, can_view_production, can_view_suppliers, can_view_customers, can_view_stock_history, can_view_stock_take, can_view_logistics, can_view_settings, can_view_financial, can_create, can_edit, can_delete, can_download, sections)
VALUES (${newUser[0].id}, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, ARRAY['dashboard', 'components', 'products', 'production', 'suppliers', 'customers', 'stock-history', 'stock-take', 'logistics', 'settings'])`;
console.log("Admin user created with permissions");
}
}
console.log("Done!");
}
resetAdminPassword().catch(console.error);
+94
View File
@@ -0,0 +1,94 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family: Arial, Helvetica, sans-serif;
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
+104
View File
@@ -0,0 +1,104 @@
import type { Config } from 'tailwindcss'
const config: Config = {
darkMode: ['class'],
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
fontFamily: {
sans: ['var(--font-inter)', 'system-ui', 'sans-serif'],
mono: ['var(--font-jetbrains-mono)', 'monospace'],
},
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
warning: {
DEFAULT: 'hsl(var(--warning))',
foreground: 'hsl(var(--warning-foreground))',
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))',
},
sidebar: {
DEFAULT: 'hsl(var(--sidebar-background))',
foreground: 'hsl(var(--sidebar-foreground))',
primary: 'hsl(var(--sidebar-primary))',
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
accent: 'hsl(var(--sidebar-accent))',
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
border: 'hsl(var(--sidebar-border))',
ring: 'hsl(var(--sidebar-ring))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'accordion-down': {
from: {
height: '0',
},
to: {
height: 'var(--radix-accordion-content-height)',
},
},
'accordion-up': {
from: {
height: 'var(--radix-accordion-content-height)',
},
to: {
height: '0',
},
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
plugins: [require('tailwindcss-animate')],
}
export default config
+33
View File
@@ -0,0 +1,33 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"target": "ES6",
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}