Compare commits
47 Commits
text-trasl
...
int
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f4e3def35 | |||
| bb151ae835 | |||
| 7ebaff322c | |||
| e17da96c22 | |||
| 3e9745c7cc | |||
| 3da3e6c60c | |||
| 85b823d614 | |||
| d20d12c1f4 | |||
| ab5f6a609d | |||
| 5ba203a8d1 | |||
| 3ca3f8e466 | |||
| 5620f6a8eb | |||
| 1583ff479c | |||
| b7d81040e6 | |||
| b249cf2000 | |||
| dfc27da142 | |||
| dde92af857 | |||
| 7b92e63a49 | |||
| 8fac8ac892 | |||
| e5183590c5 | |||
| a219825b28 | |||
| 3b4ef37e58 | |||
| eb4ad8b637 | |||
| f0e0f57e7c | |||
| 150563a8f5 | |||
| 05e1c224f0 | |||
| f1636d9057 | |||
| 44d99b0a68 | |||
| 83b3008234 | |||
| 78af87ac3c | |||
| b3c0413b7c | |||
| 4f301b1652 | |||
| debf153f58 | |||
| f3d271ded2 | |||
| 13790f2055 | |||
| bcdeafe119 | |||
| 7978884ca6 | |||
| cb7b44073c | |||
| 99ae6db064 | |||
| fcf439e369 | |||
| cecdfacd33 | |||
| 5bc698815c | |||
| 53e141f8ad | |||
| 73ccf8f4de | |||
| 0b4daed512 | |||
| 8a7d736aa9 | |||
| ce179cac62 |
@@ -21,6 +21,16 @@ jobs:
|
|||||||
java-version: '21'
|
java-version: '21'
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
|
|
||||||
|
- name: Cache Gradle
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.gradle/caches
|
||||||
|
~/.gradle/wrapper
|
||||||
|
key: gradle-${{ runner.os }}-${{ hashFiles('backend/gradle/wrapper/gradle-wrapper.properties', 'backend/**/*.gradle*', 'backend/gradle.properties') }}
|
||||||
|
restore-keys: |
|
||||||
|
gradle-${{ runner.os }}-
|
||||||
|
|
||||||
- name: Run Tests with Gradle
|
- name: Run Tests with Gradle
|
||||||
run: |
|
run: |
|
||||||
cd backend
|
cd backend
|
||||||
@@ -81,6 +91,9 @@ jobs:
|
|||||||
needs: build-and-push
|
needs: build-and-push
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set ENV
|
- name: Set ENV
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -92,7 +105,7 @@ jobs:
|
|||||||
echo "ENV=dev" >> "$GITHUB_ENV"
|
echo "ENV=dev" >> "$GITHUB_ENV"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Trigger deploy on Unraid (forced command key)
|
- name: Setup SSH key
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -120,9 +133,48 @@ jobs:
|
|||||||
# 5) Validazione: se fallisce qui, la chiave NON è valida/corrotta
|
# 5) Validazione: se fallisce qui, la chiave NON è valida/corrotta
|
||||||
ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null
|
ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null
|
||||||
|
|
||||||
# ... (resto del codice uguale)
|
|
||||||
ssh-keyscan -H "${{ secrets.SERVER_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null
|
ssh-keyscan -H "${{ secrets.SERVER_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
|
- name: Write env to server
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# 1. Start with the static env file content
|
||||||
|
cat "deploy/envs/${{ env.ENV }}.env" > /tmp/full_env.env
|
||||||
|
|
||||||
|
# 2. Determine DB credentials
|
||||||
|
if [[ "${{ env.ENV }}" == "prod" ]]; then
|
||||||
|
DB_URL="${{ secrets.DB_URL_PROD }}"
|
||||||
|
DB_USER="${{ secrets.DB_USERNAME_PROD }}"
|
||||||
|
DB_PASS="${{ secrets.DB_PASSWORD_PROD }}"
|
||||||
|
elif [[ "${{ env.ENV }}" == "int" ]]; then
|
||||||
|
DB_URL="${{ secrets.DB_URL_INT }}"
|
||||||
|
DB_USER="${{ secrets.DB_USERNAME_INT }}"
|
||||||
|
DB_PASS="${{ secrets.DB_PASSWORD_INT }}"
|
||||||
|
else
|
||||||
|
DB_URL="${{ secrets.DB_URL_DEV }}"
|
||||||
|
DB_USER="${{ secrets.DB_USERNAME_DEV }}"
|
||||||
|
DB_PASS="${{ secrets.DB_PASSWORD_DEV }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Append DB credentials
|
||||||
|
printf '\nDB_URL=%s\nDB_USERNAME=%s\nDB_PASSWORD=%s\n' \
|
||||||
|
"$DB_URL" "$DB_USER" "$DB_PASS" >> /tmp/full_env.env
|
||||||
|
|
||||||
|
# 4. Debug: print content (for debug purposes)
|
||||||
|
echo "Preparing to send env file with variables:"
|
||||||
|
grep -v "PASSWORD" /tmp/full_env.env || true
|
||||||
|
|
||||||
|
# 5. Send to server
|
||||||
|
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \
|
||||||
|
"setenv ${{ env.ENV }}" < /tmp/full_env.env
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
- name: Trigger deploy on Unraid (forced command key)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
# Aggiungiamo le opzioni di verbosità se dovesse fallire ancora,
|
# Aggiungiamo le opzioni di verbosità se dovesse fallire ancora,
|
||||||
# e assicuriamoci che l'input sia pulito
|
# e assicuriamoci che l'input sia pulito
|
||||||
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "${{ env.ENV }}"
|
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "deploy ${{ env.ENV }}"
|
||||||
|
|||||||
@@ -36,3 +36,7 @@ Questo file serve a dare contesto all'AI (Antigravity/Gemini) sulla struttura e
|
|||||||
- Per eseguire il backend serve `uvicorn`.
|
- Per eseguire il backend serve `uvicorn`.
|
||||||
- Il frontend richiede `npm install` al primo avvio.
|
- Il frontend richiede `npm install` al primo avvio.
|
||||||
- Le configurazioni di stampa (layer height, wall thickness, infill) sono attualmente hardcoded o con valori di default nel backend, ma potrebbero essere esposte come parametri API in futuro.
|
- Le configurazioni di stampa (layer height, wall thickness, infill) sono attualmente hardcoded o con valori di default nel backend, ma potrebbero essere esposte come parametri API in futuro.
|
||||||
|
|
||||||
|
## AI Agent Rules
|
||||||
|
- **No Inline Code**: Tutti i componenti Angular DEVONO usare file separati per HTML (`templateUrl`) e SCSS (`styleUrl`). È vietato usare `template` o `styles` inline nel decoratore `@Component`.
|
||||||
|
|
||||||
|
|||||||
10
Makefile
10
Makefile
@@ -1,10 +0,0 @@
|
|||||||
.PHONY: install s
|
|
||||||
install:
|
|
||||||
@echo "Installing Backend dependencies..."
|
|
||||||
cd backend && pip install -r requirements.txt || pip install fastapi uvicorn trimesh python-multipart numpy
|
|
||||||
@echo "Installing Frontend dependencies..."
|
|
||||||
cd frontend && npm install
|
|
||||||
|
|
||||||
start:
|
|
||||||
@echo "Starting development environment..."
|
|
||||||
./start.sh
|
|
||||||
@@ -19,7 +19,7 @@ RUN apt-get update && apt-get install -y \
|
|||||||
libglib2.0-0 \
|
libglib2.0-0 \
|
||||||
libgtk-3-0 \
|
libgtk-3-0 \
|
||||||
libdbus-1-3 \
|
libdbus-1-3 \
|
||||||
libwebkit2gtk-4.1-0 \
|
libwebkit2gtk-4.0-37 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install OrcaSlicer
|
# Install OrcaSlicer
|
||||||
@@ -41,4 +41,6 @@ COPY profiles ./profiles
|
|||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
CMD ["java", "-jar", "app.jar"]
|
COPY entrypoint.sh .
|
||||||
|
RUN chmod +x entrypoint.sh
|
||||||
|
ENTRYPOINT ["./entrypoint.sh"]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id 'java'
|
id 'java'
|
||||||
|
id 'application'
|
||||||
id 'org.springframework.boot' version '3.4.1'
|
id 'org.springframework.boot' version '3.4.1'
|
||||||
id 'io.spring.dependency-management' version '1.1.7'
|
id 'io.spring.dependency-management' version '1.1.7'
|
||||||
}
|
}
|
||||||
@@ -13,12 +14,18 @@ java {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
application {
|
||||||
|
mainClass = 'com.printcalculator.BackendApplication'
|
||||||
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||||
|
runtimeOnly 'org.postgresql:postgresql'
|
||||||
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
||||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||||
@@ -27,3 +34,11 @@ dependencies {
|
|||||||
tasks.named('test') {
|
tasks.named('test') {
|
||||||
useJUnitPlatform()
|
useJUnitPlatform()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.named('bootRun') {
|
||||||
|
args = ["--spring.profiles.active=local"]
|
||||||
|
}
|
||||||
|
|
||||||
|
application {
|
||||||
|
applicationDefaultJvmArgs = ["-Dspring.profiles.active=local"]
|
||||||
|
}
|
||||||
|
|||||||
15
backend/entrypoint.sh
Normal file
15
backend/entrypoint.sh
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
echo "----------------------------------------------------------------"
|
||||||
|
echo "Starting Backend Application"
|
||||||
|
echo "DB_URL: $DB_URL"
|
||||||
|
echo "DB_USERNAME: $DB_USERNAME"
|
||||||
|
echo "SLICER_PATH: $SLICER_PATH"
|
||||||
|
echo "--- ALL ENV VARS ---"
|
||||||
|
env
|
||||||
|
echo "----------------------------------------------------------------"
|
||||||
|
|
||||||
|
# Exec java with explicit properties from env
|
||||||
|
exec java -jar app.jar \
|
||||||
|
--spring.datasource.url="${DB_URL}" \
|
||||||
|
--spring.datasource.username="${DB_USERNAME}" \
|
||||||
|
--spring.datasource.password="${DB_PASSWORD}"
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
package com.printcalculator.config;
|
|
||||||
|
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
|
|
||||||
@Configuration
|
|
||||||
@ConfigurationProperties(prefix = "pricing")
|
|
||||||
public class AppProperties {
|
|
||||||
|
|
||||||
private double filamentCostPerKg;
|
|
||||||
private double machineCostPerHour;
|
|
||||||
private double energyCostPerKwh;
|
|
||||||
private double printerPowerWatts;
|
|
||||||
private double markupPercent;
|
|
||||||
|
|
||||||
private String slicerPath;
|
|
||||||
private String profilesRoot;
|
|
||||||
|
|
||||||
// Getters and Setters needed for Spring binding
|
|
||||||
|
|
||||||
public double getFilamentCostPerKg() { return filamentCostPerKg; }
|
|
||||||
public void setFilamentCostPerKg(double filamentCostPerKg) { this.filamentCostPerKg = filamentCostPerKg; }
|
|
||||||
|
|
||||||
public double getMachineCostPerHour() { return machineCostPerHour; }
|
|
||||||
public void setMachineCostPerHour(double machineCostPerHour) { this.machineCostPerHour = machineCostPerHour; }
|
|
||||||
|
|
||||||
public double getEnergyCostPerKwh() { return energyCostPerKwh; }
|
|
||||||
public void setEnergyCostPerKwh(double energyCostPerKwh) { this.energyCostPerKwh = energyCostPerKwh; }
|
|
||||||
|
|
||||||
public double getPrinterPowerWatts() { return printerPowerWatts; }
|
|
||||||
public void setPrinterPowerWatts(double printerPowerWatts) { this.printerPowerWatts = printerPowerWatts; }
|
|
||||||
|
|
||||||
public double getMarkupPercent() { return markupPercent; }
|
|
||||||
public void setMarkupPercent(double markupPercent) { this.markupPercent = markupPercent; }
|
|
||||||
|
|
||||||
// Slicer props are not under "pricing" prefix in properties file?
|
|
||||||
// Wait, in application.properties I put them at root level/custom.
|
|
||||||
// Let's fix this class to map correctly or change prefix.
|
|
||||||
// I'll make a separate section or just bind manually.
|
|
||||||
// Actually, I'll just add @Value in services for simplicity or fix the prefix structure.
|
|
||||||
// Let's stick to standard @Value for simple paths if this is messy.
|
|
||||||
// Or better, creating a dedicated SlicerProperties.
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.printcalculator.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class CorsConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addCorsMappings(CorsRegistry registry) {
|
||||||
|
registry.addMapping("/**")
|
||||||
|
.allowedOrigins("http://localhost", "http://localhost:4200", "http://localhost:80", "http://127.0.0.1")
|
||||||
|
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||||
|
.allowedHeaders("*")
|
||||||
|
.allowCredentials(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package com.printcalculator.config;
|
|
||||||
|
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
|
|
||||||
@Configuration
|
|
||||||
@ConfigurationProperties(prefix = "")
|
|
||||||
// Hack: standard prefix is usually required. I'll use @Value in service or correct this.
|
|
||||||
// Better: make SlicerConfig class.
|
|
||||||
public class SlicerConfig {
|
|
||||||
// Intentionally empty, will use @Value in service for simplicity
|
|
||||||
// or fix in next step.
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package com.printcalculator.controller;
|
||||||
|
|
||||||
|
import com.printcalculator.dto.OptionsResponse;
|
||||||
|
import com.printcalculator.entity.FilamentMaterialType;
|
||||||
|
import com.printcalculator.entity.FilamentVariant;
|
||||||
|
import com.printcalculator.entity.*; // This line replaces specific entity imports
|
||||||
|
import com.printcalculator.repository.FilamentMaterialTypeRepository;
|
||||||
|
import com.printcalculator.repository.FilamentVariantRepository;
|
||||||
|
import com.printcalculator.repository.LayerHeightOptionRepository;
|
||||||
|
import com.printcalculator.repository.NozzleOptionRepository;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
public class OptionsController {
|
||||||
|
|
||||||
|
private final FilamentMaterialTypeRepository materialRepo;
|
||||||
|
private final FilamentVariantRepository variantRepo;
|
||||||
|
private final LayerHeightOptionRepository layerHeightRepo;
|
||||||
|
private final NozzleOptionRepository nozzleRepo;
|
||||||
|
|
||||||
|
public OptionsController(FilamentMaterialTypeRepository materialRepo,
|
||||||
|
FilamentVariantRepository variantRepo,
|
||||||
|
LayerHeightOptionRepository layerHeightRepo,
|
||||||
|
NozzleOptionRepository nozzleRepo) {
|
||||||
|
this.materialRepo = materialRepo;
|
||||||
|
this.variantRepo = variantRepo;
|
||||||
|
this.layerHeightRepo = layerHeightRepo;
|
||||||
|
this.nozzleRepo = nozzleRepo;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/calculator/options")
|
||||||
|
public ResponseEntity<OptionsResponse> getOptions() {
|
||||||
|
// 1. Materials & Variants
|
||||||
|
List<FilamentMaterialType> types = materialRepo.findAll();
|
||||||
|
List<FilamentVariant> allVariants = variantRepo.findAll();
|
||||||
|
|
||||||
|
List<OptionsResponse.MaterialOption> materialOptions = types.stream()
|
||||||
|
.map(type -> {
|
||||||
|
List<OptionsResponse.VariantOption> variants = allVariants.stream()
|
||||||
|
.filter(v -> v.getFilamentMaterialType().getId().equals(type.getId()) && v.getIsActive())
|
||||||
|
.map(v -> new OptionsResponse.VariantOption(
|
||||||
|
v.getVariantDisplayName(),
|
||||||
|
v.getColorName(),
|
||||||
|
getColorHex(v.getColorName()), // Need helper or store hex in DB
|
||||||
|
v.getStockSpools().doubleValue() <= 0
|
||||||
|
))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
// Only include material if it has active variants
|
||||||
|
if (variants.isEmpty()) return null;
|
||||||
|
|
||||||
|
return new OptionsResponse.MaterialOption(
|
||||||
|
type.getMaterialCode(),
|
||||||
|
type.getMaterialCode() + (type.getIsFlexible() ? " (Flexible)" : " (Standard)"),
|
||||||
|
variants
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.filter(m -> m != null)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
// 2. Qualities (Static as per user request)
|
||||||
|
List<OptionsResponse.QualityOption> qualities = List.of(
|
||||||
|
new OptionsResponse.QualityOption("draft", "Draft"),
|
||||||
|
new OptionsResponse.QualityOption("standard", "Standard"),
|
||||||
|
new OptionsResponse.QualityOption("extra_fine", "High Definition")
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Infill Patterns (Static as per user request)
|
||||||
|
List<OptionsResponse.InfillPatternOption> patterns = List.of(
|
||||||
|
new OptionsResponse.InfillPatternOption("grid", "Grid"),
|
||||||
|
new OptionsResponse.InfillPatternOption("gyroid", "Gyroid"),
|
||||||
|
new OptionsResponse.InfillPatternOption("cubic", "Cubic")
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Layer Heights
|
||||||
|
List<OptionsResponse.LayerHeightOptionDTO> layers = layerHeightRepo.findAll().stream()
|
||||||
|
.filter(l -> l.getIsActive())
|
||||||
|
.sorted(Comparator.comparing(LayerHeightOption::getLayerHeightMm))
|
||||||
|
.map(l -> new OptionsResponse.LayerHeightOptionDTO(
|
||||||
|
l.getLayerHeightMm().doubleValue(),
|
||||||
|
String.format("%.2f mm", l.getLayerHeightMm())
|
||||||
|
))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
// 5. Nozzles
|
||||||
|
List<OptionsResponse.NozzleOptionDTO> nozzles = nozzleRepo.findAll().stream()
|
||||||
|
.filter(n -> n.getIsActive())
|
||||||
|
.sorted(Comparator.comparing(NozzleOption::getNozzleDiameterMm))
|
||||||
|
.map(n -> new OptionsResponse.NozzleOptionDTO(
|
||||||
|
n.getNozzleDiameterMm().doubleValue(),
|
||||||
|
String.format("%.1f mm%s", n.getNozzleDiameterMm(),
|
||||||
|
n.getExtraNozzleChangeFeeChf().doubleValue() > 0
|
||||||
|
? String.format(" (+ %.2f CHF)", n.getExtraNozzleChangeFeeChf())
|
||||||
|
: " (Standard)")
|
||||||
|
))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
return ResponseEntity.ok(new OptionsResponse(materialOptions, qualities, patterns, layers, nozzles));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Temporary helper until we add hex to DB
|
||||||
|
private String getColorHex(String colorName) {
|
||||||
|
String lower = colorName.toLowerCase();
|
||||||
|
if (lower.contains("black") || lower.contains("nero")) return "#1a1a1a";
|
||||||
|
if (lower.contains("white") || lower.contains("bianco")) return "#f5f5f5";
|
||||||
|
if (lower.contains("blue") || lower.contains("blu")) return "#1976d2";
|
||||||
|
if (lower.contains("red") || lower.contains("rosso")) return "#d32f2f";
|
||||||
|
if (lower.contains("green") || lower.contains("verde")) return "#388e3c";
|
||||||
|
if (lower.contains("orange") || lower.contains("arancione")) return "#ffa726";
|
||||||
|
if (lower.contains("grey") || lower.contains("gray") || lower.contains("grigio")) {
|
||||||
|
if (lower.contains("dark") || lower.contains("scuro")) return "#424242";
|
||||||
|
return "#bdbdbd";
|
||||||
|
}
|
||||||
|
if (lower.contains("purple") || lower.contains("viola")) return "#7b1fa2";
|
||||||
|
if (lower.contains("yellow") || lower.contains("giallo")) return "#fbc02d";
|
||||||
|
return "#9e9e9e"; // Default grey
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,43 +1,87 @@
|
|||||||
package com.printcalculator.controller;
|
package com.printcalculator.controller;
|
||||||
|
|
||||||
|
import com.printcalculator.entity.PrinterMachine;
|
||||||
import com.printcalculator.model.PrintStats;
|
import com.printcalculator.model.PrintStats;
|
||||||
import com.printcalculator.model.QuoteResult;
|
import com.printcalculator.model.QuoteResult;
|
||||||
|
import com.printcalculator.repository.PrinterMachineRepository;
|
||||||
import com.printcalculator.service.QuoteCalculator;
|
import com.printcalculator.service.QuoteCalculator;
|
||||||
import com.printcalculator.service.SlicerService;
|
import com.printcalculator.service.SlicerService;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@CrossOrigin(origins = "*") // Allow all for development
|
|
||||||
public class QuoteController {
|
public class QuoteController {
|
||||||
|
|
||||||
private final SlicerService slicerService;
|
private final SlicerService slicerService;
|
||||||
private final QuoteCalculator quoteCalculator;
|
private final QuoteCalculator quoteCalculator;
|
||||||
|
private final PrinterMachineRepository machineRepo;
|
||||||
|
|
||||||
// Defaults
|
// Defaults (using aliases defined in ProfileManager)
|
||||||
private static final String DEFAULT_MACHINE = "Bambu_Lab_A1_machine";
|
private static final String DEFAULT_FILAMENT = "pla_basic";
|
||||||
private static final String DEFAULT_FILAMENT = "Bambu_PLA_Basic";
|
private static final String DEFAULT_PROCESS = "standard";
|
||||||
private static final String DEFAULT_PROCESS = "Bambu_Process_0.20_Standard";
|
|
||||||
|
|
||||||
public QuoteController(SlicerService slicerService, QuoteCalculator quoteCalculator) {
|
public QuoteController(SlicerService slicerService, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo) {
|
||||||
this.slicerService = slicerService;
|
this.slicerService = slicerService;
|
||||||
this.quoteCalculator = quoteCalculator;
|
this.quoteCalculator = quoteCalculator;
|
||||||
|
this.machineRepo = machineRepo;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/api/quote")
|
@PostMapping("/api/quote")
|
||||||
public ResponseEntity<QuoteResult> calculateQuote(
|
public ResponseEntity<QuoteResult> calculateQuote(
|
||||||
@RequestParam("file") MultipartFile file,
|
@RequestParam("file") MultipartFile file,
|
||||||
@RequestParam(value = "machine", defaultValue = DEFAULT_MACHINE) String machine,
|
@RequestParam(value = "filament", required = false, defaultValue = DEFAULT_FILAMENT) String filament,
|
||||||
@RequestParam(value = "filament", defaultValue = DEFAULT_FILAMENT) String filament,
|
@RequestParam(value = "process", required = false) String process,
|
||||||
@RequestParam(value = "process", defaultValue = DEFAULT_PROCESS) String process
|
@RequestParam(value = "quality", required = false) String quality,
|
||||||
|
// Advanced Options
|
||||||
|
@RequestParam(value = "infill_density", required = false) Integer infillDensity,
|
||||||
|
@RequestParam(value = "infill_pattern", required = false) String infillPattern,
|
||||||
|
@RequestParam(value = "layer_height", required = false) Double layerHeight,
|
||||||
|
@RequestParam(value = "nozzle_diameter", required = false) Double nozzleDiameter,
|
||||||
|
@RequestParam(value = "support_enabled", required = false) Boolean supportEnabled
|
||||||
) throws IOException {
|
) throws IOException {
|
||||||
|
|
||||||
return processRequest(file, machine, filament, process);
|
// ... process selection logic ...
|
||||||
|
String actualProcess = process;
|
||||||
|
if (actualProcess == null || actualProcess.isEmpty()) {
|
||||||
|
if (quality != null && !quality.isEmpty()) {
|
||||||
|
actualProcess = quality;
|
||||||
|
} else {
|
||||||
|
actualProcess = DEFAULT_PROCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare Overrides
|
||||||
|
Map<String, String> processOverrides = new HashMap<>();
|
||||||
|
Map<String, String> machineOverrides = new HashMap<>();
|
||||||
|
|
||||||
|
if (infillDensity != null) {
|
||||||
|
processOverrides.put("sparse_infill_density", infillDensity + "%");
|
||||||
|
}
|
||||||
|
if (infillPattern != null && !infillPattern.isEmpty()) {
|
||||||
|
processOverrides.put("sparse_infill_pattern", infillPattern);
|
||||||
|
}
|
||||||
|
if (layerHeight != null) {
|
||||||
|
processOverrides.put("layer_height", String.valueOf(layerHeight));
|
||||||
|
}
|
||||||
|
if (supportEnabled != null) {
|
||||||
|
processOverrides.put("enable_support", supportEnabled ? "1" : "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nozzleDiameter != null) {
|
||||||
|
machineOverrides.put("nozzle_diameter", String.valueOf(nozzleDiameter));
|
||||||
|
// Also need to ensure the printer profile is compatible or just override?
|
||||||
|
// Usually nozzle diameter changes require a different printer profile or deep overrides.
|
||||||
|
// For now, we trust the override key works on the base profile.
|
||||||
|
}
|
||||||
|
|
||||||
|
return processRequest(file, filament, actualProcess, machineOverrides, processOverrides);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/calculate/stl")
|
@PostMapping("/calculate/stl")
|
||||||
@@ -45,30 +89,37 @@ public class QuoteController {
|
|||||||
@RequestParam("file") MultipartFile file
|
@RequestParam("file") MultipartFile file
|
||||||
) throws IOException {
|
) throws IOException {
|
||||||
// Legacy endpoint uses defaults
|
// Legacy endpoint uses defaults
|
||||||
return processRequest(file, DEFAULT_MACHINE, DEFAULT_FILAMENT, DEFAULT_PROCESS);
|
return processRequest(file, DEFAULT_FILAMENT, DEFAULT_PROCESS, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ResponseEntity<QuoteResult> processRequest(MultipartFile file, String machine, String filament, String process) throws IOException {
|
private ResponseEntity<QuoteResult> processRequest(MultipartFile file, String filament, String process,
|
||||||
|
Map<String, String> machineOverrides,
|
||||||
|
Map<String, String> processOverrides) throws IOException {
|
||||||
if (file.isEmpty()) {
|
if (file.isEmpty()) {
|
||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch Default Active Machine
|
||||||
|
PrinterMachine machine = machineRepo.findFirstByIsActiveTrue()
|
||||||
|
.orElseThrow(() -> new IOException("No active printer found in database"));
|
||||||
|
|
||||||
// Save uploaded file temporarily
|
// Save uploaded file temporarily
|
||||||
Path tempInput = Files.createTempFile("upload_", "_" + file.getOriginalFilename());
|
Path tempInput = Files.createTempFile("upload_", "_" + file.getOriginalFilename());
|
||||||
try {
|
try {
|
||||||
file.transferTo(tempInput.toFile());
|
file.transferTo(tempInput.toFile());
|
||||||
|
|
||||||
// Slice
|
String slicerMachineProfile = "bambu_a1"; // TODO: Add to PrinterMachine entity
|
||||||
PrintStats stats = slicerService.slice(tempInput.toFile(), machine, filament, process);
|
|
||||||
|
|
||||||
// Calculate Quote
|
PrintStats stats = slicerService.slice(tempInput.toFile(), slicerMachineProfile, filament, process, machineOverrides, processOverrides);
|
||||||
QuoteResult result = quoteCalculator.calculate(stats);
|
|
||||||
|
// Calculate Quote (Pass machine display name for pricing lookup)
|
||||||
|
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filament);
|
||||||
|
|
||||||
return ResponseEntity.ok(result);
|
return ResponseEntity.ok(result);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
return ResponseEntity.internalServerError().build(); // Simplify error handling for now
|
return ResponseEntity.internalServerError().build();
|
||||||
} finally {
|
} finally {
|
||||||
Files.deleteIfExists(tempInput);
|
Files.deleteIfExists(tempInput);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.printcalculator.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record OptionsResponse(
|
||||||
|
List<MaterialOption> materials,
|
||||||
|
List<QualityOption> qualities,
|
||||||
|
List<InfillPatternOption> infillPatterns,
|
||||||
|
List<LayerHeightOptionDTO> layerHeights,
|
||||||
|
List<NozzleOptionDTO> nozzleDiameters
|
||||||
|
) {
|
||||||
|
public record MaterialOption(String code, String label, List<VariantOption> variants) {}
|
||||||
|
public record VariantOption(String name, String colorName, String hexColor, boolean isOutOfStock) {}
|
||||||
|
public record QualityOption(String id, String label) {}
|
||||||
|
public record InfillPatternOption(String id, String label) {}
|
||||||
|
public record LayerHeightOptionDTO(double value, String label) {}
|
||||||
|
public record NozzleOptionDTO(double value, String label) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.printcalculator.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import org.hibernate.annotations.ColumnDefault;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "filament_material_type")
|
||||||
|
public class FilamentMaterialType {
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "filament_material_type_id", nullable = false)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "material_code", nullable = false, length = Integer.MAX_VALUE)
|
||||||
|
private String materialCode;
|
||||||
|
|
||||||
|
@ColumnDefault("false")
|
||||||
|
@Column(name = "is_flexible", nullable = false)
|
||||||
|
private Boolean isFlexible;
|
||||||
|
|
||||||
|
@ColumnDefault("false")
|
||||||
|
@Column(name = "is_technical", nullable = false)
|
||||||
|
private Boolean isTechnical;
|
||||||
|
|
||||||
|
@Column(name = "technical_type_label", length = Integer.MAX_VALUE)
|
||||||
|
private String technicalTypeLabel;
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMaterialCode() {
|
||||||
|
return materialCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMaterialCode(String materialCode) {
|
||||||
|
this.materialCode = materialCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getIsFlexible() {
|
||||||
|
return isFlexible;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsFlexible(Boolean isFlexible) {
|
||||||
|
this.isFlexible = isFlexible;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getIsTechnical() {
|
||||||
|
return isTechnical;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsTechnical(Boolean isTechnical) {
|
||||||
|
this.isTechnical = isTechnical;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTechnicalTypeLabel() {
|
||||||
|
return technicalTypeLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTechnicalTypeLabel(String technicalTypeLabel) {
|
||||||
|
this.technicalTypeLabel = technicalTypeLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package com.printcalculator.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import org.hibernate.annotations.ColumnDefault;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "filament_variant")
|
||||||
|
public class FilamentVariant {
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "filament_variant_id", nullable = false)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
@JoinColumn(name = "filament_material_type_id", nullable = false)
|
||||||
|
private FilamentMaterialType filamentMaterialType;
|
||||||
|
|
||||||
|
@Column(name = "variant_display_name", nullable = false, length = Integer.MAX_VALUE)
|
||||||
|
private String variantDisplayName;
|
||||||
|
|
||||||
|
@Column(name = "color_name", nullable = false, length = Integer.MAX_VALUE)
|
||||||
|
private String colorName;
|
||||||
|
|
||||||
|
@ColumnDefault("false")
|
||||||
|
@Column(name = "is_matte", nullable = false)
|
||||||
|
private Boolean isMatte;
|
||||||
|
|
||||||
|
@ColumnDefault("false")
|
||||||
|
@Column(name = "is_special", nullable = false)
|
||||||
|
private Boolean isSpecial;
|
||||||
|
|
||||||
|
@Column(name = "cost_chf_per_kg", nullable = false, precision = 10, scale = 2)
|
||||||
|
private BigDecimal costChfPerKg;
|
||||||
|
|
||||||
|
@ColumnDefault("0.000")
|
||||||
|
@Column(name = "stock_spools", nullable = false, precision = 6, scale = 3)
|
||||||
|
private BigDecimal stockSpools;
|
||||||
|
|
||||||
|
@ColumnDefault("1.000")
|
||||||
|
@Column(name = "spool_net_kg", nullable = false, precision = 6, scale = 3)
|
||||||
|
private BigDecimal spoolNetKg;
|
||||||
|
|
||||||
|
@ColumnDefault("true")
|
||||||
|
@Column(name = "is_active", nullable = false)
|
||||||
|
private Boolean isActive;
|
||||||
|
|
||||||
|
@ColumnDefault("now()")
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
private OffsetDateTime createdAt;
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FilamentMaterialType getFilamentMaterialType() {
|
||||||
|
return filamentMaterialType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFilamentMaterialType(FilamentMaterialType filamentMaterialType) {
|
||||||
|
this.filamentMaterialType = filamentMaterialType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getVariantDisplayName() {
|
||||||
|
return variantDisplayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setVariantDisplayName(String variantDisplayName) {
|
||||||
|
this.variantDisplayName = variantDisplayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getColorName() {
|
||||||
|
return colorName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setColorName(String colorName) {
|
||||||
|
this.colorName = colorName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getIsMatte() {
|
||||||
|
return isMatte;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsMatte(Boolean isMatte) {
|
||||||
|
this.isMatte = isMatte;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getIsSpecial() {
|
||||||
|
return isSpecial;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsSpecial(Boolean isSpecial) {
|
||||||
|
this.isSpecial = isSpecial;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getCostChfPerKg() {
|
||||||
|
return costChfPerKg;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCostChfPerKg(BigDecimal costChfPerKg) {
|
||||||
|
this.costChfPerKg = costChfPerKg;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getStockSpools() {
|
||||||
|
return stockSpools;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStockSpools(BigDecimal stockSpools) {
|
||||||
|
this.stockSpools = stockSpools;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getSpoolNetKg() {
|
||||||
|
return spoolNetKg;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSpoolNetKg(BigDecimal spoolNetKg) {
|
||||||
|
this.spoolNetKg = spoolNetKg;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getIsActive() {
|
||||||
|
return isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsActive(Boolean isActive) {
|
||||||
|
this.isActive = isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OffsetDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(OffsetDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.printcalculator.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import org.hibernate.annotations.Immutable;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Immutable
|
||||||
|
@Table(name = "filament_variant_stock_kg")
|
||||||
|
public class FilamentVariantStockKg {
|
||||||
|
@Id
|
||||||
|
@Column(name = "filament_variant_id")
|
||||||
|
private Long filamentVariantId;
|
||||||
|
|
||||||
|
@Column(name = "stock_spools", precision = 6, scale = 3)
|
||||||
|
private BigDecimal stockSpools;
|
||||||
|
|
||||||
|
@Column(name = "spool_net_kg", precision = 6, scale = 3)
|
||||||
|
private BigDecimal spoolNetKg;
|
||||||
|
|
||||||
|
@Column(name = "stock_kg")
|
||||||
|
private BigDecimal stockKg;
|
||||||
|
|
||||||
|
public Long getFilamentVariantId() {
|
||||||
|
return filamentVariantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getStockSpools() {
|
||||||
|
return stockSpools;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getSpoolNetKg() {
|
||||||
|
return spoolNetKg;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getStockKg() {
|
||||||
|
return stockKg;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.printcalculator.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import org.hibernate.annotations.ColumnDefault;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "infill_pattern")
|
||||||
|
public class InfillPattern {
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "infill_pattern_id", nullable = false)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "pattern_code", nullable = false, length = Integer.MAX_VALUE)
|
||||||
|
private String patternCode;
|
||||||
|
|
||||||
|
@Column(name = "display_name", nullable = false, length = Integer.MAX_VALUE)
|
||||||
|
private String displayName;
|
||||||
|
|
||||||
|
@ColumnDefault("true")
|
||||||
|
@Column(name = "is_active", nullable = false)
|
||||||
|
private Boolean isActive;
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPatternCode() {
|
||||||
|
return patternCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPatternCode(String patternCode) {
|
||||||
|
this.patternCode = patternCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDisplayName() {
|
||||||
|
return displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDisplayName(String displayName) {
|
||||||
|
this.displayName = displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getIsActive() {
|
||||||
|
return isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsActive(Boolean isActive) {
|
||||||
|
this.isActive = isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.printcalculator.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import org.hibernate.annotations.ColumnDefault;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "layer_height_option")
|
||||||
|
public class LayerHeightOption {
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "layer_height_option_id", nullable = false)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "layer_height_mm", nullable = false, precision = 5, scale = 3)
|
||||||
|
private BigDecimal layerHeightMm;
|
||||||
|
|
||||||
|
@ColumnDefault("1.000")
|
||||||
|
@Column(name = "time_multiplier", nullable = false, precision = 6, scale = 3)
|
||||||
|
private BigDecimal timeMultiplier;
|
||||||
|
|
||||||
|
@ColumnDefault("true")
|
||||||
|
@Column(name = "is_active", nullable = false)
|
||||||
|
private Boolean isActive;
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getLayerHeightMm() {
|
||||||
|
return layerHeightMm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLayerHeightMm(BigDecimal layerHeightMm) {
|
||||||
|
this.layerHeightMm = layerHeightMm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getTimeMultiplier() {
|
||||||
|
return timeMultiplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTimeMultiplier(BigDecimal timeMultiplier) {
|
||||||
|
this.timeMultiplier = timeMultiplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getIsActive() {
|
||||||
|
return isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsActive(Boolean isActive) {
|
||||||
|
this.isActive = isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package com.printcalculator.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import org.hibernate.annotations.ColumnDefault;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "layer_height_profile")
|
||||||
|
public class LayerHeightProfile {
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "layer_height_profile_id", nullable = false)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "profile_name", nullable = false, length = Integer.MAX_VALUE)
|
||||||
|
private String profileName;
|
||||||
|
|
||||||
|
@Column(name = "min_layer_height_mm", nullable = false, precision = 5, scale = 3)
|
||||||
|
private BigDecimal minLayerHeightMm;
|
||||||
|
|
||||||
|
@Column(name = "max_layer_height_mm", nullable = false, precision = 5, scale = 3)
|
||||||
|
private BigDecimal maxLayerHeightMm;
|
||||||
|
|
||||||
|
@Column(name = "default_layer_height_mm", nullable = false, precision = 5, scale = 3)
|
||||||
|
private BigDecimal defaultLayerHeightMm;
|
||||||
|
|
||||||
|
@ColumnDefault("1.000")
|
||||||
|
@Column(name = "time_multiplier", nullable = false, precision = 6, scale = 3)
|
||||||
|
private BigDecimal timeMultiplier;
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getProfileName() {
|
||||||
|
return profileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setProfileName(String profileName) {
|
||||||
|
this.profileName = profileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getMinLayerHeightMm() {
|
||||||
|
return minLayerHeightMm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMinLayerHeightMm(BigDecimal minLayerHeightMm) {
|
||||||
|
this.minLayerHeightMm = minLayerHeightMm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getMaxLayerHeightMm() {
|
||||||
|
return maxLayerHeightMm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMaxLayerHeightMm(BigDecimal maxLayerHeightMm) {
|
||||||
|
this.maxLayerHeightMm = maxLayerHeightMm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getDefaultLayerHeightMm() {
|
||||||
|
return defaultLayerHeightMm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDefaultLayerHeightMm(BigDecimal defaultLayerHeightMm) {
|
||||||
|
this.defaultLayerHeightMm = defaultLayerHeightMm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getTimeMultiplier() {
|
||||||
|
return timeMultiplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTimeMultiplier(BigDecimal timeMultiplier) {
|
||||||
|
this.timeMultiplier = timeMultiplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package com.printcalculator.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import org.hibernate.annotations.ColumnDefault;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "nozzle_option")
|
||||||
|
public class NozzleOption {
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "nozzle_option_id", nullable = false)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "nozzle_diameter_mm", nullable = false, precision = 4, scale = 2)
|
||||||
|
private BigDecimal nozzleDiameterMm;
|
||||||
|
|
||||||
|
@ColumnDefault("0")
|
||||||
|
@Column(name = "owned_quantity", nullable = false)
|
||||||
|
private Integer ownedQuantity;
|
||||||
|
|
||||||
|
@ColumnDefault("0.00")
|
||||||
|
@Column(name = "extra_nozzle_change_fee_chf", nullable = false, precision = 10, scale = 2)
|
||||||
|
private BigDecimal extraNozzleChangeFeeChf;
|
||||||
|
|
||||||
|
@ColumnDefault("true")
|
||||||
|
@Column(name = "is_active", nullable = false)
|
||||||
|
private Boolean isActive;
|
||||||
|
|
||||||
|
@ColumnDefault("now()")
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
private OffsetDateTime createdAt;
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getNozzleDiameterMm() {
|
||||||
|
return nozzleDiameterMm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNozzleDiameterMm(BigDecimal nozzleDiameterMm) {
|
||||||
|
this.nozzleDiameterMm = nozzleDiameterMm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getOwnedQuantity() {
|
||||||
|
return ownedQuantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOwnedQuantity(Integer ownedQuantity) {
|
||||||
|
this.ownedQuantity = ownedQuantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getExtraNozzleChangeFeeChf() {
|
||||||
|
return extraNozzleChangeFeeChf;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setExtraNozzleChangeFeeChf(BigDecimal extraNozzleChangeFeeChf) {
|
||||||
|
this.extraNozzleChangeFeeChf = extraNozzleChangeFeeChf;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getIsActive() {
|
||||||
|
return isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsActive(Boolean isActive) {
|
||||||
|
this.isActive = isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OffsetDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(OffsetDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package com.printcalculator.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import org.hibernate.annotations.ColumnDefault;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "pricing_policy")
|
||||||
|
public class PricingPolicy {
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "pricing_policy_id", nullable = false)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "policy_name", nullable = false, length = Integer.MAX_VALUE)
|
||||||
|
private String policyName;
|
||||||
|
|
||||||
|
@Column(name = "valid_from", nullable = false)
|
||||||
|
private OffsetDateTime validFrom;
|
||||||
|
|
||||||
|
@Column(name = "valid_to")
|
||||||
|
private OffsetDateTime validTo;
|
||||||
|
|
||||||
|
@Column(name = "electricity_cost_chf_per_kwh", nullable = false, precision = 10, scale = 6)
|
||||||
|
private BigDecimal electricityCostChfPerKwh;
|
||||||
|
|
||||||
|
@ColumnDefault("20.000")
|
||||||
|
@Column(name = "markup_percent", nullable = false, precision = 6, scale = 3)
|
||||||
|
private BigDecimal markupPercent;
|
||||||
|
|
||||||
|
@ColumnDefault("0.00")
|
||||||
|
@Column(name = "fixed_job_fee_chf", nullable = false, precision = 10, scale = 2)
|
||||||
|
private BigDecimal fixedJobFeeChf;
|
||||||
|
|
||||||
|
@ColumnDefault("0.00")
|
||||||
|
@Column(name = "nozzle_change_base_fee_chf", nullable = false, precision = 10, scale = 2)
|
||||||
|
private BigDecimal nozzleChangeBaseFeeChf;
|
||||||
|
|
||||||
|
@ColumnDefault("0.00")
|
||||||
|
@Column(name = "cad_cost_chf_per_hour", nullable = false, precision = 10, scale = 2)
|
||||||
|
private BigDecimal cadCostChfPerHour;
|
||||||
|
|
||||||
|
@ColumnDefault("true")
|
||||||
|
@Column(name = "is_active", nullable = false)
|
||||||
|
private Boolean isActive;
|
||||||
|
|
||||||
|
@ColumnDefault("now()")
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
private OffsetDateTime createdAt;
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPolicyName() {
|
||||||
|
return policyName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPolicyName(String policyName) {
|
||||||
|
this.policyName = policyName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OffsetDateTime getValidFrom() {
|
||||||
|
return validFrom;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setValidFrom(OffsetDateTime validFrom) {
|
||||||
|
this.validFrom = validFrom;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OffsetDateTime getValidTo() {
|
||||||
|
return validTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setValidTo(OffsetDateTime validTo) {
|
||||||
|
this.validTo = validTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getElectricityCostChfPerKwh() {
|
||||||
|
return electricityCostChfPerKwh;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setElectricityCostChfPerKwh(BigDecimal electricityCostChfPerKwh) {
|
||||||
|
this.electricityCostChfPerKwh = electricityCostChfPerKwh;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getMarkupPercent() {
|
||||||
|
return markupPercent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMarkupPercent(BigDecimal markupPercent) {
|
||||||
|
this.markupPercent = markupPercent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getFixedJobFeeChf() {
|
||||||
|
return fixedJobFeeChf;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFixedJobFeeChf(BigDecimal fixedJobFeeChf) {
|
||||||
|
this.fixedJobFeeChf = fixedJobFeeChf;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getNozzleChangeBaseFeeChf() {
|
||||||
|
return nozzleChangeBaseFeeChf;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNozzleChangeBaseFeeChf(BigDecimal nozzleChangeBaseFeeChf) {
|
||||||
|
this.nozzleChangeBaseFeeChf = nozzleChangeBaseFeeChf;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getCadCostChfPerHour() {
|
||||||
|
return cadCostChfPerHour;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCadCostChfPerHour(BigDecimal cadCostChfPerHour) {
|
||||||
|
this.cadCostChfPerHour = cadCostChfPerHour;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getIsActive() {
|
||||||
|
return isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsActive(Boolean isActive) {
|
||||||
|
this.isActive = isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OffsetDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(OffsetDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.printcalculator.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "pricing_policy_machine_hour_tier")
|
||||||
|
public class PricingPolicyMachineHourTier {
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "pricing_policy_machine_hour_tier_id", nullable = false)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
@JoinColumn(name = "pricing_policy_id", nullable = false)
|
||||||
|
private PricingPolicy pricingPolicy;
|
||||||
|
|
||||||
|
@Column(name = "tier_start_hours", nullable = false, precision = 10, scale = 2)
|
||||||
|
private BigDecimal tierStartHours;
|
||||||
|
|
||||||
|
@Column(name = "tier_end_hours", precision = 10, scale = 2)
|
||||||
|
private BigDecimal tierEndHours;
|
||||||
|
|
||||||
|
@Column(name = "machine_cost_chf_per_hour", nullable = false, precision = 10, scale = 2)
|
||||||
|
private BigDecimal machineCostChfPerHour;
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PricingPolicy getPricingPolicy() {
|
||||||
|
return pricingPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPricingPolicy(PricingPolicy pricingPolicy) {
|
||||||
|
this.pricingPolicy = pricingPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getTierStartHours() {
|
||||||
|
return tierStartHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTierStartHours(BigDecimal tierStartHours) {
|
||||||
|
this.tierStartHours = tierStartHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getTierEndHours() {
|
||||||
|
return tierEndHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTierEndHours(BigDecimal tierEndHours) {
|
||||||
|
this.tierEndHours = tierEndHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getMachineCostChfPerHour() {
|
||||||
|
return machineCostChfPerHour;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMachineCostChfPerHour(BigDecimal machineCostChfPerHour) {
|
||||||
|
this.machineCostChfPerHour = machineCostChfPerHour;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.printcalculator.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import org.hibernate.annotations.Immutable;
|
||||||
|
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Immutable
|
||||||
|
@Table(name = "printer_fleet_current")
|
||||||
|
public class PrinterFleetCurrent {
|
||||||
|
@Id
|
||||||
|
@Column(name = "fleet_id")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "weighted_average_power_watts")
|
||||||
|
private Integer weightedAveragePowerWatts;
|
||||||
|
|
||||||
|
@Column(name = "fleet_max_build_x_mm")
|
||||||
|
private Integer fleetMaxBuildXMm;
|
||||||
|
|
||||||
|
@Column(name = "fleet_max_build_y_mm")
|
||||||
|
private Integer fleetMaxBuildYMm;
|
||||||
|
|
||||||
|
@Column(name = "fleet_max_build_z_mm")
|
||||||
|
private Integer fleetMaxBuildZMm;
|
||||||
|
|
||||||
|
public Integer getWeightedAveragePowerWatts() {
|
||||||
|
return weightedAveragePowerWatts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getFleetMaxBuildXMm() {
|
||||||
|
return fleetMaxBuildXMm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getFleetMaxBuildYMm() {
|
||||||
|
return fleetMaxBuildYMm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getFleetMaxBuildZMm() {
|
||||||
|
return fleetMaxBuildZMm;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package com.printcalculator.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import org.hibernate.annotations.ColumnDefault;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "printer_machine")
|
||||||
|
public class PrinterMachine {
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "printer_machine_id", nullable = false)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "printer_display_name", nullable = false, length = Integer.MAX_VALUE)
|
||||||
|
private String printerDisplayName;
|
||||||
|
|
||||||
|
@Column(name = "build_volume_x_mm", nullable = false)
|
||||||
|
private Integer buildVolumeXMm;
|
||||||
|
|
||||||
|
@Column(name = "build_volume_y_mm", nullable = false)
|
||||||
|
private Integer buildVolumeYMm;
|
||||||
|
|
||||||
|
@Column(name = "build_volume_z_mm", nullable = false)
|
||||||
|
private Integer buildVolumeZMm;
|
||||||
|
|
||||||
|
@Column(name = "power_watts", nullable = false)
|
||||||
|
private Integer powerWatts;
|
||||||
|
|
||||||
|
@ColumnDefault("1.000")
|
||||||
|
@Column(name = "fleet_weight", nullable = false, precision = 6, scale = 3)
|
||||||
|
private BigDecimal fleetWeight;
|
||||||
|
|
||||||
|
@ColumnDefault("true")
|
||||||
|
@Column(name = "is_active", nullable = false)
|
||||||
|
private Boolean isActive;
|
||||||
|
|
||||||
|
@ColumnDefault("now()")
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
private OffsetDateTime createdAt;
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPrinterDisplayName() {
|
||||||
|
return printerDisplayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPrinterDisplayName(String printerDisplayName) {
|
||||||
|
this.printerDisplayName = printerDisplayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getBuildVolumeXMm() {
|
||||||
|
return buildVolumeXMm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBuildVolumeXMm(Integer buildVolumeXMm) {
|
||||||
|
this.buildVolumeXMm = buildVolumeXMm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getBuildVolumeYMm() {
|
||||||
|
return buildVolumeYMm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBuildVolumeYMm(Integer buildVolumeYMm) {
|
||||||
|
this.buildVolumeYMm = buildVolumeYMm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getBuildVolumeZMm() {
|
||||||
|
return buildVolumeZMm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBuildVolumeZMm(Integer buildVolumeZMm) {
|
||||||
|
this.buildVolumeZMm = buildVolumeZMm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getPowerWatts() {
|
||||||
|
return powerWatts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPowerWatts(Integer powerWatts) {
|
||||||
|
this.powerWatts = powerWatts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getFleetWeight() {
|
||||||
|
return fleetWeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFleetWeight(BigDecimal fleetWeight) {
|
||||||
|
this.fleetWeight = fleetWeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getIsActive() {
|
||||||
|
return isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsActive(Boolean isActive) {
|
||||||
|
this.isActive = isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OffsetDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(OffsetDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package com.printcalculator.model;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
|
|
||||||
public record CostBreakdown(
|
|
||||||
BigDecimal materialCost,
|
|
||||||
BigDecimal machineCost,
|
|
||||||
BigDecimal energyCost,
|
|
||||||
BigDecimal subtotal,
|
|
||||||
BigDecimal markupAmount
|
|
||||||
) {}
|
|
||||||
@@ -1,12 +1,31 @@
|
|||||||
package com.printcalculator.model;
|
package com.printcalculator.model;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
public class QuoteResult {
|
||||||
import java.util.List;
|
private double totalPrice;
|
||||||
|
private String currency;
|
||||||
|
private PrintStats stats;
|
||||||
|
private double setupCost;
|
||||||
|
|
||||||
public record QuoteResult(
|
public QuoteResult(double totalPrice, String currency, PrintStats stats, double setupCost) {
|
||||||
BigDecimal totalPrice,
|
this.totalPrice = totalPrice;
|
||||||
String currency,
|
this.currency = currency;
|
||||||
PrintStats stats,
|
this.stats = stats;
|
||||||
CostBreakdown breakdown,
|
this.setupCost = setupCost;
|
||||||
List<String> notes
|
}
|
||||||
) {}
|
|
||||||
|
public double getTotalPrice() {
|
||||||
|
return totalPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCurrency() {
|
||||||
|
return currency;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PrintStats getStats() {
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getSetupCost() {
|
||||||
|
return setupCost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.printcalculator.repository;
|
||||||
|
|
||||||
|
import com.printcalculator.entity.FilamentMaterialType;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface FilamentMaterialTypeRepository extends JpaRepository<FilamentMaterialType, Long> {
|
||||||
|
Optional<FilamentMaterialType> findByMaterialCode(String materialCode);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.printcalculator.repository;
|
||||||
|
|
||||||
|
import com.printcalculator.entity.FilamentVariant;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import com.printcalculator.entity.FilamentMaterialType;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface FilamentVariantRepository extends JpaRepository<FilamentVariant, Long> {
|
||||||
|
// We try to match by color name if possible, or get first active
|
||||||
|
Optional<FilamentVariant> findByFilamentMaterialTypeAndColorName(FilamentMaterialType type, String colorName);
|
||||||
|
Optional<FilamentVariant> findFirstByFilamentMaterialTypeAndIsActiveTrue(FilamentMaterialType type);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.printcalculator.repository;
|
||||||
|
|
||||||
|
import com.printcalculator.entity.InfillPattern;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface InfillPatternRepository extends JpaRepository<InfillPattern, Long> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.printcalculator.repository;
|
||||||
|
|
||||||
|
import com.printcalculator.entity.LayerHeightOption;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface LayerHeightOptionRepository extends JpaRepository<LayerHeightOption, Long> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.printcalculator.repository;
|
||||||
|
|
||||||
|
import com.printcalculator.entity.LayerHeightProfile;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface LayerHeightProfileRepository extends JpaRepository<LayerHeightProfile, Long> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.printcalculator.repository;
|
||||||
|
|
||||||
|
import com.printcalculator.entity.NozzleOption;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface NozzleOptionRepository extends JpaRepository<NozzleOption, Long> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.printcalculator.repository;
|
||||||
|
|
||||||
|
import com.printcalculator.entity.PricingPolicyMachineHourTier;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import com.printcalculator.entity.PricingPolicy;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface PricingPolicyMachineHourTierRepository extends JpaRepository<PricingPolicyMachineHourTier, Long> {
|
||||||
|
List<PricingPolicyMachineHourTier> findAllByPricingPolicyOrderByTierStartHoursAsc(PricingPolicy pricingPolicy);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.printcalculator.repository;
|
||||||
|
|
||||||
|
import com.printcalculator.entity.PricingPolicy;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface PricingPolicyRepository extends JpaRepository<PricingPolicy, Long> {
|
||||||
|
PricingPolicy findFirstByIsActiveTrueOrderByValidFromDesc();
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.printcalculator.repository;
|
||||||
|
|
||||||
|
import com.printcalculator.entity.PrinterMachine;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface PrinterMachineRepository extends JpaRepository<PrinterMachine, Long> {
|
||||||
|
Optional<PrinterMachine> findByPrinterDisplayName(String printerDisplayName);
|
||||||
|
Optional<PrinterMachine> findFirstByIsActiveTrue();
|
||||||
|
}
|
||||||
@@ -13,9 +13,21 @@ import java.util.regex.Pattern;
|
|||||||
@Service
|
@Service
|
||||||
public class GCodeParser {
|
public class GCodeParser {
|
||||||
|
|
||||||
private static final Pattern TIME_PATTERN = Pattern.compile("estimated printing time = (.*)");
|
// OrcaSlicer/BambuStudio format
|
||||||
private static final Pattern FILAMENT_G_PATTERN = Pattern.compile("filament used \\[g\\] = (.*)");
|
// ; estimated printing time = 1h 2m 3s
|
||||||
private static final Pattern FILAMENT_MM_PATTERN = Pattern.compile("filament used \\[mm\\] = (.*)");
|
// ; filament used [g] = 12.34
|
||||||
|
// ; filament used [mm] = 1234.56
|
||||||
|
private static final Pattern TOTAL_ESTIMATED_TIME_PATTERN = Pattern.compile(
|
||||||
|
";\\s*.*total\\s+estimated\\s+time\\s*[:=]\\s*([^;]+)",
|
||||||
|
Pattern.CASE_INSENSITIVE);
|
||||||
|
private static final Pattern MODEL_PRINTING_TIME_PATTERN = Pattern.compile(
|
||||||
|
";\\s*.*model\\s+printing\\s+time\\s*[:=]\\s*([^;]+)",
|
||||||
|
Pattern.CASE_INSENSITIVE);
|
||||||
|
private static final Pattern TIME_PATTERN = Pattern.compile(
|
||||||
|
";\\s*(?:estimated\\s+printing\\s+time|estimated\\s+print\\s+time|print\\s+time).*?[:=]\\s*(.*)",
|
||||||
|
Pattern.CASE_INSENSITIVE);
|
||||||
|
private static final Pattern FILAMENT_G_PATTERN = Pattern.compile(";\\s*filament used \\[g\\]\\s*=\\s*(.*)");
|
||||||
|
private static final Pattern FILAMENT_MM_PATTERN = Pattern.compile(";\\s*filament used \\[mm\\]\\s*=\\s*(.*)");
|
||||||
|
|
||||||
public PrintStats parse(File gcodeFile) throws IOException {
|
public PrintStats parse(File gcodeFile) throws IOException {
|
||||||
long seconds = 0;
|
long seconds = 0;
|
||||||
@@ -25,12 +37,33 @@ public class GCodeParser {
|
|||||||
|
|
||||||
try (BufferedReader reader = new BufferedReader(new FileReader(gcodeFile))) {
|
try (BufferedReader reader = new BufferedReader(new FileReader(gcodeFile))) {
|
||||||
String line;
|
String line;
|
||||||
// Scan first 500 lines for efficiency
|
|
||||||
int count = 0;
|
// Scan entire file as metadata is often at the end
|
||||||
while ((line = reader.readLine()) != null && count < 500) {
|
while ((line = reader.readLine()) != null) {
|
||||||
line = line.trim();
|
line = line.trim();
|
||||||
|
|
||||||
|
// OrcaSlicer comments start with ;
|
||||||
if (!line.startsWith(";")) {
|
if (!line.startsWith(";")) {
|
||||||
count++;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.toLowerCase().contains("estimated printing time")) {
|
||||||
|
System.out.println("DEBUG: Found potential time line: '" + line + "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
Matcher totalTimeMatcher = TOTAL_ESTIMATED_TIME_PATTERN.matcher(line);
|
||||||
|
if (totalTimeMatcher.find()) {
|
||||||
|
timeFormatted = totalTimeMatcher.group(1).trim();
|
||||||
|
seconds = parseTimeString(timeFormatted);
|
||||||
|
System.out.println("GCodeParser: Found total estimated time: " + timeFormatted + " (" + seconds + "s)");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Matcher modelTimeMatcher = MODEL_PRINTING_TIME_PATTERN.matcher(line);
|
||||||
|
if (modelTimeMatcher.find()) {
|
||||||
|
timeFormatted = modelTimeMatcher.group(1).trim();
|
||||||
|
seconds = parseTimeString(timeFormatted);
|
||||||
|
System.out.println("GCodeParser: Found model printing time: " + timeFormatted + " (" + seconds + "s)");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,12 +71,14 @@ public class GCodeParser {
|
|||||||
if (timeMatcher.find()) {
|
if (timeMatcher.find()) {
|
||||||
timeFormatted = timeMatcher.group(1).trim();
|
timeFormatted = timeMatcher.group(1).trim();
|
||||||
seconds = parseTimeString(timeFormatted);
|
seconds = parseTimeString(timeFormatted);
|
||||||
|
System.out.println("GCodeParser: Found time: " + timeFormatted + " (" + seconds + "s)");
|
||||||
}
|
}
|
||||||
|
|
||||||
Matcher weightMatcher = FILAMENT_G_PATTERN.matcher(line);
|
Matcher weightMatcher = FILAMENT_G_PATTERN.matcher(line);
|
||||||
if (weightMatcher.find()) {
|
if (weightMatcher.find()) {
|
||||||
try {
|
try {
|
||||||
weightG = Double.parseDouble(weightMatcher.group(1).trim());
|
weightG = Double.parseDouble(weightMatcher.group(1).trim());
|
||||||
|
System.out.println("GCodeParser: Found weight: " + weightG + "g");
|
||||||
} catch (NumberFormatException ignored) {}
|
} catch (NumberFormatException ignored) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,9 +86,9 @@ public class GCodeParser {
|
|||||||
if (lengthMatcher.find()) {
|
if (lengthMatcher.find()) {
|
||||||
try {
|
try {
|
||||||
lengthMm = Double.parseDouble(lengthMatcher.group(1).trim());
|
lengthMm = Double.parseDouble(lengthMatcher.group(1).trim());
|
||||||
|
System.out.println("GCodeParser: Found length: " + lengthMm + "mm");
|
||||||
} catch (NumberFormatException ignored) {}
|
} catch (NumberFormatException ignored) {}
|
||||||
}
|
}
|
||||||
count++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,21 +96,60 @@ public class GCodeParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private long parseTimeString(String timeStr) {
|
private long parseTimeString(String timeStr) {
|
||||||
// Formats: "1d 2h 3m 4s" or "1h 20m 10s"
|
// Formats: "1d 2h 3m 4s", "1h 20m 10s", "01:23:45", "12:34"
|
||||||
long totalSeconds = 0;
|
String lower = timeStr.toLowerCase();
|
||||||
|
double totalSeconds = 0;
|
||||||
|
boolean matched = false;
|
||||||
|
|
||||||
Matcher d = Pattern.compile("(\\d+)d").matcher(timeStr);
|
Matcher d = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*d").matcher(lower);
|
||||||
if (d.find()) totalSeconds += Long.parseLong(d.group(1)) * 86400;
|
if (d.find()) {
|
||||||
|
totalSeconds += Double.parseDouble(d.group(1)) * 86400;
|
||||||
|
matched = true;
|
||||||
|
}
|
||||||
|
|
||||||
Matcher h = Pattern.compile("(\\d+)h").matcher(timeStr);
|
Matcher h = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*h").matcher(lower);
|
||||||
if (h.find()) totalSeconds += Long.parseLong(h.group(1)) * 3600;
|
if (h.find()) {
|
||||||
|
totalSeconds += Double.parseDouble(h.group(1)) * 3600;
|
||||||
|
matched = true;
|
||||||
|
}
|
||||||
|
|
||||||
Matcher m = Pattern.compile("(\\d+)m").matcher(timeStr);
|
Matcher m = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*m").matcher(lower);
|
||||||
if (m.find()) totalSeconds += Long.parseLong(m.group(1)) * 60;
|
if (m.find()) {
|
||||||
|
totalSeconds += Double.parseDouble(m.group(1)) * 60;
|
||||||
|
matched = true;
|
||||||
|
}
|
||||||
|
|
||||||
Matcher s = Pattern.compile("(\\d+)s").matcher(timeStr);
|
Matcher s = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*s").matcher(lower);
|
||||||
if (s.find()) totalSeconds += Long.parseLong(s.group(1));
|
if (s.find()) {
|
||||||
|
totalSeconds += Double.parseDouble(s.group(1));
|
||||||
|
matched = true;
|
||||||
|
}
|
||||||
|
|
||||||
return totalSeconds;
|
if (matched) {
|
||||||
|
return Math.round(totalSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
long daySeconds = 0;
|
||||||
|
Matcher dayPrefix = Pattern.compile("(\\d+)\\s*d").matcher(lower);
|
||||||
|
if (dayPrefix.find()) {
|
||||||
|
daySeconds = Long.parseLong(dayPrefix.group(1)) * 86400;
|
||||||
|
}
|
||||||
|
|
||||||
|
Matcher hms = Pattern.compile("(\\d{1,2}):(\\d{2}):(\\d{2})").matcher(lower);
|
||||||
|
if (hms.find()) {
|
||||||
|
long hours = Long.parseLong(hms.group(1));
|
||||||
|
long minutes = Long.parseLong(hms.group(2));
|
||||||
|
long seconds = Long.parseLong(hms.group(3));
|
||||||
|
return daySeconds + hours * 3600 + minutes * 60 + seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
Matcher ms = Pattern.compile("(\\d{1,2}):(\\d{2})").matcher(lower);
|
||||||
|
if (ms.find()) {
|
||||||
|
long minutes = Long.parseLong(ms.group(1));
|
||||||
|
long seconds = Long.parseLong(ms.group(2));
|
||||||
|
return daySeconds + minutes * 60 + seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import java.util.Iterator;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.logging.Logger;
|
import java.util.logging.Logger;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.HashMap;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class ProfileManager {
|
public class ProfileManager {
|
||||||
@@ -22,9 +24,31 @@ public class ProfileManager {
|
|||||||
private final String profilesRoot;
|
private final String profilesRoot;
|
||||||
private final ObjectMapper mapper;
|
private final ObjectMapper mapper;
|
||||||
|
|
||||||
|
private final Map<String, String> profileAliases;
|
||||||
|
|
||||||
public ProfileManager(@Value("${profiles.root:profiles}") String profilesRoot, ObjectMapper mapper) {
|
public ProfileManager(@Value("${profiles.root:profiles}") String profilesRoot, ObjectMapper mapper) {
|
||||||
this.profilesRoot = profilesRoot;
|
this.profilesRoot = profilesRoot;
|
||||||
this.mapper = mapper;
|
this.mapper = mapper;
|
||||||
|
this.profileAliases = new HashMap<>();
|
||||||
|
initializeAliases();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void initializeAliases() {
|
||||||
|
// Machine Aliases
|
||||||
|
profileAliases.put("bambu_a1", "Bambu Lab A1 0.4 nozzle");
|
||||||
|
|
||||||
|
// Material Aliases
|
||||||
|
profileAliases.put("pla_basic", "Bambu PLA Basic @BBL A1");
|
||||||
|
profileAliases.put("petg_basic", "Bambu PETG Basic @BBL A1");
|
||||||
|
profileAliases.put("tpu_95a", "Bambu TPU 95A @BBL A1");
|
||||||
|
|
||||||
|
// Quality/Process Aliases
|
||||||
|
profileAliases.put("draft", "0.24mm Draft @BBL A1");
|
||||||
|
profileAliases.put("standard", "0.20mm Standard @BBL A1"); // or 0.20mm Standard @BBL A1
|
||||||
|
profileAliases.put("extra_fine", "0.08mm High Quality @BBL A1");
|
||||||
|
|
||||||
|
// Additional aliases from error logs
|
||||||
|
profileAliases.put("Bambu_Process_0.20_Standard", "0.20mm Standard @BBL A1");
|
||||||
}
|
}
|
||||||
|
|
||||||
public ObjectNode getMergedProfile(String profileName, String type) throws IOException {
|
public ObjectNode getMergedProfile(String profileName, String type) throws IOException {
|
||||||
@@ -36,9 +60,12 @@ public class ProfileManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Path findProfileFile(String name, String type) {
|
private Path findProfileFile(String name, String type) {
|
||||||
|
// Check aliases first
|
||||||
|
String resolvedName = profileAliases.getOrDefault(name, name);
|
||||||
|
|
||||||
// Simple search: look for name.json in the profiles_root recursively
|
// Simple search: look for name.json in the profiles_root recursively
|
||||||
// Type could be "machine", "process", "filament" to narrow down, but for now global search
|
// Type could be "machine", "process", "filament" to narrow down, but for now global search
|
||||||
String filename = name.endsWith(".json") ? name : name + ".json";
|
String filename = resolvedName.endsWith(".json") ? resolvedName : resolvedName + ".json";
|
||||||
|
|
||||||
try (Stream<Path> stream = Files.walk(Paths.get(profilesRoot))) {
|
try (Stream<Path> stream = Files.walk(Paths.get(profilesRoot))) {
|
||||||
Optional<Path> found = stream
|
Optional<Path> found = stream
|
||||||
|
|||||||
@@ -1,59 +1,158 @@
|
|||||||
package com.printcalculator.service;
|
package com.printcalculator.service;
|
||||||
|
|
||||||
import com.printcalculator.config.AppProperties;
|
|
||||||
import com.printcalculator.model.CostBreakdown;
|
import com.printcalculator.entity.FilamentMaterialType;
|
||||||
|
import com.printcalculator.entity.FilamentVariant;
|
||||||
|
import com.printcalculator.entity.PricingPolicy;
|
||||||
|
import com.printcalculator.entity.PricingPolicyMachineHourTier;
|
||||||
|
import com.printcalculator.entity.PrinterMachine;
|
||||||
import com.printcalculator.model.PrintStats;
|
import com.printcalculator.model.PrintStats;
|
||||||
import com.printcalculator.model.QuoteResult;
|
import com.printcalculator.model.QuoteResult;
|
||||||
|
import com.printcalculator.repository.FilamentMaterialTypeRepository;
|
||||||
|
import com.printcalculator.repository.FilamentVariantRepository;
|
||||||
|
import com.printcalculator.repository.PricingPolicyMachineHourTierRepository;
|
||||||
|
import com.printcalculator.repository.PricingPolicyRepository;
|
||||||
|
import com.printcalculator.repository.PrinterMachineRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class QuoteCalculator {
|
public class QuoteCalculator {
|
||||||
|
|
||||||
private final AppProperties props;
|
private final PricingPolicyRepository pricingRepo;
|
||||||
|
private final PricingPolicyMachineHourTierRepository tierRepo;
|
||||||
|
private final PrinterMachineRepository machineRepo;
|
||||||
|
private final FilamentMaterialTypeRepository materialRepo;
|
||||||
|
private final FilamentVariantRepository variantRepo;
|
||||||
|
|
||||||
public QuoteCalculator(AppProperties props) {
|
public QuoteCalculator(PricingPolicyRepository pricingRepo,
|
||||||
this.props = props;
|
PricingPolicyMachineHourTierRepository tierRepo,
|
||||||
|
PrinterMachineRepository machineRepo,
|
||||||
|
FilamentMaterialTypeRepository materialRepo,
|
||||||
|
FilamentVariantRepository variantRepo) {
|
||||||
|
this.pricingRepo = pricingRepo;
|
||||||
|
this.tierRepo = tierRepo;
|
||||||
|
this.machineRepo = machineRepo;
|
||||||
|
this.materialRepo = materialRepo;
|
||||||
|
this.variantRepo = variantRepo;
|
||||||
}
|
}
|
||||||
|
|
||||||
public QuoteResult calculate(PrintStats stats) {
|
public QuoteResult calculate(PrintStats stats, String machineName, String filamentProfileName) {
|
||||||
|
// 1. Fetch Active Policy
|
||||||
|
PricingPolicy policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc();
|
||||||
|
if (policy == null) {
|
||||||
|
throw new RuntimeException("No active pricing policy found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch Machine Info
|
||||||
|
// Map "bambu_a1" -> "BambuLab A1" or similar?
|
||||||
|
// Ideally we should use the display name from DB.
|
||||||
|
// For now, if machineName is a code, we might need a mapping or just fuzzy search.
|
||||||
|
// Let's assume machineName is mapped or we search by display name.
|
||||||
|
// If not found, fallback to first active.
|
||||||
|
PrinterMachine machine = machineRepo.findByPrinterDisplayName(machineName).orElse(null);
|
||||||
|
if (machine == null) {
|
||||||
|
// Try "BambuLab A1" if code was "bambu_a1" logic or just get first active
|
||||||
|
machine = machineRepo.findFirstByIsActiveTrue()
|
||||||
|
.orElseThrow(() -> new RuntimeException("No active printer found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fetch Filament Info
|
||||||
|
// filamentProfileName might be "bambu_pla_basic_black" or "Generic PLA"
|
||||||
|
// We try to extract material code (PLA, PETG)
|
||||||
|
String materialCode = detectMaterialCode(filamentProfileName);
|
||||||
|
FilamentMaterialType materialType = materialRepo.findByMaterialCode(materialCode)
|
||||||
|
.orElseThrow(() -> new RuntimeException("Unknown material type: " + materialCode));
|
||||||
|
|
||||||
|
// Try to find specific variant (e.g. by color if we could parse it)
|
||||||
|
// For now, get default/first active variant for this material
|
||||||
|
FilamentVariant variant = variantRepo.findFirstByFilamentMaterialTypeAndIsActiveTrue(materialType)
|
||||||
|
.orElseThrow(() -> new RuntimeException("No active variant for material: " + materialCode));
|
||||||
|
|
||||||
|
|
||||||
|
// --- CALCULATIONS ---
|
||||||
|
|
||||||
// Material Cost: (weight / 1000) * costPerKg
|
// Material Cost: (weight / 1000) * costPerKg
|
||||||
BigDecimal weightKg = BigDecimal.valueOf(stats.filamentWeightGrams()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
|
BigDecimal weightKg = BigDecimal.valueOf(stats.filamentWeightGrams()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
|
||||||
BigDecimal materialCost = weightKg.multiply(BigDecimal.valueOf(props.getFilamentCostPerKg()));
|
BigDecimal materialCost = weightKg.multiply(variant.getCostChfPerKg());
|
||||||
|
|
||||||
// Machine Cost: (seconds / 3600) * costPerHour
|
// Machine Cost: Tiered
|
||||||
BigDecimal hours = BigDecimal.valueOf(stats.printTimeSeconds()).divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP);
|
BigDecimal totalHours = BigDecimal.valueOf(stats.printTimeSeconds()).divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP);
|
||||||
BigDecimal machineCost = hours.multiply(BigDecimal.valueOf(props.getMachineCostPerHour()));
|
BigDecimal machineCost = calculateMachineCost(policy, totalHours);
|
||||||
|
|
||||||
// Energy Cost: (watts / 1000) * hours * costPerKwh
|
// Energy Cost: (watts / 1000) * hours * costPerKwh
|
||||||
BigDecimal kw = BigDecimal.valueOf(props.getPrinterPowerWatts()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
|
BigDecimal kw = BigDecimal.valueOf(machine.getPowerWatts()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
|
||||||
BigDecimal kwh = kw.multiply(hours);
|
BigDecimal kwh = kw.multiply(totalHours);
|
||||||
BigDecimal energyCost = kwh.multiply(BigDecimal.valueOf(props.getEnergyCostPerKwh()));
|
BigDecimal energyCost = kwh.multiply(policy.getElectricityCostChfPerKwh());
|
||||||
|
|
||||||
// Subtotal
|
// Subtotal (Costs + Fixed Fees)
|
||||||
BigDecimal subtotal = materialCost.add(machineCost).add(energyCost);
|
BigDecimal fixedFee = policy.getFixedJobFeeChf();
|
||||||
|
BigDecimal subtotal = materialCost.add(machineCost).add(energyCost).add(fixedFee);
|
||||||
|
|
||||||
// Markup
|
// Markup
|
||||||
BigDecimal markupFactor = BigDecimal.valueOf(1.0 + (props.getMarkupPercent() / 100.0));
|
// Markup is percentage (e.g. 20.0)
|
||||||
|
BigDecimal markupFactor = BigDecimal.ONE.add(policy.getMarkupPercent().divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP));
|
||||||
BigDecimal totalPrice = subtotal.multiply(markupFactor).setScale(2, RoundingMode.HALF_UP);
|
BigDecimal totalPrice = subtotal.multiply(markupFactor).setScale(2, RoundingMode.HALF_UP);
|
||||||
|
|
||||||
BigDecimal markupAmount = totalPrice.subtract(subtotal);
|
return new QuoteResult(totalPrice.doubleValue(), "CHF", stats, fixedFee.doubleValue());
|
||||||
|
}
|
||||||
|
|
||||||
CostBreakdown breakdown = new CostBreakdown(
|
private BigDecimal calculateMachineCost(PricingPolicy policy, BigDecimal hours) {
|
||||||
materialCost.setScale(2, RoundingMode.HALF_UP),
|
List<PricingPolicyMachineHourTier> tiers = tierRepo.findAllByPricingPolicyOrderByTierStartHoursAsc(policy);
|
||||||
machineCost.setScale(2, RoundingMode.HALF_UP),
|
if (tiers.isEmpty()) {
|
||||||
energyCost.setScale(2, RoundingMode.HALF_UP),
|
return BigDecimal.ZERO; // Should not happen if DB is correct
|
||||||
subtotal.setScale(2, RoundingMode.HALF_UP),
|
}
|
||||||
markupAmount.setScale(2, RoundingMode.HALF_UP)
|
|
||||||
);
|
|
||||||
|
|
||||||
List<String> notes = new ArrayList<>();
|
BigDecimal remainingHours = hours;
|
||||||
notes.add("Generated via Dynamic Slicer (Java Backend)");
|
BigDecimal totalCost = BigDecimal.ZERO;
|
||||||
|
BigDecimal processedHours = BigDecimal.ZERO;
|
||||||
|
|
||||||
return new QuoteResult(totalPrice, "EUR", stats, breakdown, notes);
|
for (PricingPolicyMachineHourTier tier : tiers) {
|
||||||
|
if (remainingHours.compareTo(BigDecimal.ZERO) <= 0) break;
|
||||||
|
|
||||||
|
BigDecimal tierStart = tier.getTierStartHours();
|
||||||
|
BigDecimal tierEnd = tier.getTierEndHours(); // can be null for infinity
|
||||||
|
|
||||||
|
// Determine duration in this tier
|
||||||
|
// Valid duration in this tier = (min(tierEnd, totalHours) - tierStart)
|
||||||
|
// But logic is simpler: we consume hours sequentially?
|
||||||
|
// "0-10h @ 2CHF, 10-20h @ 1.5CHF" implies:
|
||||||
|
// 5h job -> 5 * 2
|
||||||
|
// 15h job -> 10 * 2 + 5 * 1.5
|
||||||
|
|
||||||
|
BigDecimal tierDuration;
|
||||||
|
|
||||||
|
// Max hours applicable in this tier relative to 0
|
||||||
|
BigDecimal tierLimit = (tierEnd != null) ? tierEnd : BigDecimal.valueOf(Long.MAX_VALUE);
|
||||||
|
|
||||||
|
// The amount of hours falling into this bucket
|
||||||
|
// Upper bound for this calculation is min(totalHours, tierLimit)
|
||||||
|
// Lower bound is tierStart
|
||||||
|
// So hours in this bucket = max(0, min(totalHours, tierLimit) - tierStart)
|
||||||
|
|
||||||
|
BigDecimal upper = hours.min(tierLimit);
|
||||||
|
BigDecimal lower = tierStart;
|
||||||
|
|
||||||
|
if (upper.compareTo(lower) > 0) {
|
||||||
|
BigDecimal hoursInTier = upper.subtract(lower);
|
||||||
|
totalCost = totalCost.add(hoursInTier.multiply(tier.getMachineCostChfPerHour()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalCost;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String detectMaterialCode(String profileName) {
|
||||||
|
String lower = profileName.toLowerCase();
|
||||||
|
if (lower.contains("petg")) return "PETG";
|
||||||
|
if (lower.contains("tpu")) return "TPU";
|
||||||
|
if (lower.contains("abs")) return "ABS";
|
||||||
|
if (lower.contains("nylon")) return "Nylon";
|
||||||
|
if (lower.contains("asa")) return "ASA";
|
||||||
|
// Default to PLA
|
||||||
|
return "PLA";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import java.nio.file.Files;
|
|||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.logging.Logger;
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
@@ -36,12 +37,21 @@ public class SlicerService {
|
|||||||
this.mapper = mapper;
|
this.mapper = mapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
public PrintStats slice(File inputStl, String machineName, String filamentName, String processName) throws IOException {
|
public PrintStats slice(File inputStl, String machineName, String filamentName, String processName,
|
||||||
|
Map<String, String> machineOverrides, Map<String, String> processOverrides) throws IOException {
|
||||||
// 1. Prepare Profiles
|
// 1. Prepare Profiles
|
||||||
ObjectNode machineProfile = profileManager.getMergedProfile(machineName, "machine");
|
ObjectNode machineProfile = profileManager.getMergedProfile(machineName, "machine");
|
||||||
ObjectNode filamentProfile = profileManager.getMergedProfile(filamentName, "filament");
|
ObjectNode filamentProfile = profileManager.getMergedProfile(filamentName, "filament");
|
||||||
ObjectNode processProfile = profileManager.getMergedProfile(processName, "process");
|
ObjectNode processProfile = profileManager.getMergedProfile(processName, "process");
|
||||||
|
|
||||||
|
// Apply Overrides
|
||||||
|
if (machineOverrides != null) {
|
||||||
|
machineOverrides.forEach(machineProfile::put);
|
||||||
|
}
|
||||||
|
if (processOverrides != null) {
|
||||||
|
processOverrides.forEach(processProfile::put);
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Create Temp Dir
|
// 2. Create Temp Dir
|
||||||
Path tempDir = Files.createTempDirectory("slicer_job_");
|
Path tempDir = Files.createTempDirectory("slicer_job_");
|
||||||
try {
|
try {
|
||||||
@@ -55,12 +65,16 @@ public class SlicerService {
|
|||||||
|
|
||||||
// 3. Build Command
|
// 3. Build Command
|
||||||
// --load-settings "machine.json;process.json" --load-filaments "filament.json"
|
// --load-settings "machine.json;process.json" --load-filaments "filament.json"
|
||||||
String settingsArg = mFile.getAbsolutePath() + ";" + pFile.getAbsolutePath();
|
|
||||||
|
|
||||||
List<String> command = new ArrayList<>();
|
List<String> command = new ArrayList<>();
|
||||||
command.add(slicerPath);
|
command.add(slicerPath);
|
||||||
|
|
||||||
|
// Load machine settings
|
||||||
command.add("--load-settings");
|
command.add("--load-settings");
|
||||||
command.add(settingsArg);
|
command.add(mFile.getAbsolutePath());
|
||||||
|
|
||||||
|
// Load process settings
|
||||||
|
command.add("--load-settings");
|
||||||
|
command.add(pFile.getAbsolutePath());
|
||||||
command.add("--load-filaments");
|
command.add("--load-filaments");
|
||||||
command.add(fFile.getAbsolutePath());
|
command.add(fFile.getAbsolutePath());
|
||||||
command.add("--ensure-on-bed");
|
command.add("--ensure-on-bed");
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
spring.application.name=backend
|
spring.application.name=backend
|
||||||
server.port=8000
|
server.port=8000
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:5432/printcalc}
|
||||||
|
spring.datasource.username=${DB_USERNAME:printcalc}
|
||||||
|
spring.datasource.password=${DB_PASSWORD:printcalc_secret}
|
||||||
|
spring.jpa.hibernate.ddl-auto=update
|
||||||
|
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
|
||||||
|
|
||||||
|
|
||||||
# Slicer Configuration
|
# Slicer Configuration
|
||||||
# Map SLICER_PATH env var if present (default to /opt/orcaslicer/AppRun or Mac path)
|
# Map SLICER_PATH env var if present (default to /opt/orcaslicer/AppRun or Mac path)
|
||||||
slicer.path=${SLICER_PATH:/Applications/OrcaSlicer.app/Contents/MacOS/OrcaSlicer}
|
slicer.path=${SLICER_PATH:/Applications/OrcaSlicer.app/Contents/MacOS/OrcaSlicer}
|
||||||
profiles.root=${PROFILES_DIR:profiles}
|
profiles.root=${PROFILES_DIR:profiles}
|
||||||
|
|
||||||
# Pricing Configuration
|
|
||||||
# Mapped to legacy environment variables for Docker compatibility
|
|
||||||
pricing.filament-cost-per-kg=${FILAMENT_COST_PER_KG:25.0}
|
|
||||||
pricing.machine-cost-per-hour=${MACHINE_COST_PER_HOUR:2.0}
|
|
||||||
pricing.energy-cost-per-kwh=${ENERGY_COST_PER_KWH:0.30}
|
|
||||||
pricing.printer-power-watts=${PRINTER_POWER_WATTS:150.0}
|
|
||||||
pricing.markup-percent=${MARKUP_PERCENT:20.0}
|
|
||||||
|
|
||||||
# File Upload Limits
|
# File Upload Limits
|
||||||
spring.servlet.multipart.max-file-size=200MB
|
spring.servlet.multipart.max-file-size=200MB
|
||||||
|
|||||||
@@ -52,6 +52,62 @@ class GCodeParserTest {
|
|||||||
assertEquals(750, stats.printTimeSeconds()); // 12*60 + 30
|
assertEquals(750, stats.printTimeSeconds()); // 12*60 + 30
|
||||||
assertEquals(5.0, stats.filamentWeightGrams(), 0.001);
|
assertEquals(5.0, stats.filamentWeightGrams(), 0.001);
|
||||||
|
|
||||||
|
tempFile.delete();
|
||||||
|
}
|
||||||
|
@Test
|
||||||
|
void parse_withExtraTextInTimeLine_returnsCorrectStats() throws IOException {
|
||||||
|
// Arrange
|
||||||
|
File tempFile = File.createTempFile("test_extra", ".gcode");
|
||||||
|
try (FileWriter writer = new FileWriter(tempFile)) {
|
||||||
|
writer.write("; generated by OrcaSlicer\n");
|
||||||
|
// Simulate the variation that was causing issues
|
||||||
|
writer.write("; estimated printing time (normal mode) = 1h 2m 3s\n");
|
||||||
|
writer.write("; filament used [g] = 10.5\n");
|
||||||
|
writer.write("; filament used [mm] = 3000.0\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
GCodeParser parser = new GCodeParser();
|
||||||
|
PrintStats stats = parser.parse(tempFile);
|
||||||
|
|
||||||
|
assertEquals(3723L, stats.printTimeSeconds());
|
||||||
|
assertEquals("1h 2m 3s", stats.printTimeFormatted());
|
||||||
|
|
||||||
|
tempFile.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parse_colonFormattedTime_returnsCorrectStats() throws IOException {
|
||||||
|
File tempFile = File.createTempFile("test_colon", ".gcode");
|
||||||
|
try (FileWriter writer = new FileWriter(tempFile)) {
|
||||||
|
writer.write("; generated by OrcaSlicer\n");
|
||||||
|
writer.write("; print time: 01:02:03\n");
|
||||||
|
writer.write("; filament used [g] = 7.5\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
GCodeParser parser = new GCodeParser();
|
||||||
|
PrintStats stats = parser.parse(tempFile);
|
||||||
|
|
||||||
|
assertEquals(3723L, stats.printTimeSeconds());
|
||||||
|
assertEquals("01:02:03", stats.printTimeFormatted());
|
||||||
|
|
||||||
|
tempFile.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parse_totalEstimatedTimeInline_returnsCorrectStats() throws IOException {
|
||||||
|
File tempFile = File.createTempFile("test_total", ".gcode");
|
||||||
|
try (FileWriter writer = new FileWriter(tempFile)) {
|
||||||
|
writer.write("; generated by OrcaSlicer\n");
|
||||||
|
writer.write("; model printing time: 5m 17s; total estimated time: 5m 21s\n");
|
||||||
|
writer.write("; filament used [g] = 2.0\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
GCodeParser parser = new GCodeParser();
|
||||||
|
PrintStats stats = parser.parse(tempFile);
|
||||||
|
|
||||||
|
assertEquals(321L, stats.printTimeSeconds());
|
||||||
|
assertEquals("5m 21s", stats.printTimeFormatted());
|
||||||
|
|
||||||
tempFile.delete();
|
tempFile.delete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
366
db.sql
Normal file
366
db.sql
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
create table printer_machine
|
||||||
|
(
|
||||||
|
printer_machine_id bigserial primary key,
|
||||||
|
printer_display_name text not null unique,
|
||||||
|
|
||||||
|
build_volume_x_mm integer not null check (build_volume_x_mm > 0),
|
||||||
|
build_volume_y_mm integer not null check (build_volume_y_mm > 0),
|
||||||
|
build_volume_z_mm integer not null check (build_volume_z_mm > 0),
|
||||||
|
|
||||||
|
power_watts integer not null check (power_watts > 0),
|
||||||
|
|
||||||
|
fleet_weight numeric(6, 3) not null default 1.000,
|
||||||
|
|
||||||
|
is_active boolean not null default true,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create view printer_fleet_current as
|
||||||
|
select 1 as fleet_id,
|
||||||
|
case
|
||||||
|
when sum(fleet_weight) = 0 then null
|
||||||
|
else round(sum(power_watts * fleet_weight) / sum(fleet_weight))::integer
|
||||||
|
end as weighted_average_power_watts,
|
||||||
|
max(build_volume_x_mm) as fleet_max_build_x_mm,
|
||||||
|
max(build_volume_y_mm) as fleet_max_build_y_mm,
|
||||||
|
max(build_volume_z_mm) as fleet_max_build_z_mm
|
||||||
|
from printer_machine
|
||||||
|
where is_active = true;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
create table filament_material_type
|
||||||
|
(
|
||||||
|
filament_material_type_id bigserial primary key,
|
||||||
|
material_code text not null unique, -- PLA, PETG, TPU, ASA...
|
||||||
|
is_flexible boolean not null default false, -- sì/no
|
||||||
|
is_technical boolean not null default false, -- sì/no
|
||||||
|
technical_type_label text -- es: "alta temperatura", "rinforzato", ecc.
|
||||||
|
);
|
||||||
|
|
||||||
|
create table filament_variant
|
||||||
|
(
|
||||||
|
filament_variant_id bigserial primary key,
|
||||||
|
filament_material_type_id bigint not null references filament_material_type (filament_material_type_id),
|
||||||
|
|
||||||
|
variant_display_name text not null, -- es: "PLA Nero Opaco BrandX"
|
||||||
|
color_name text not null, -- Nero, Bianco, ecc.
|
||||||
|
is_matte boolean not null default false,
|
||||||
|
is_special boolean not null default false,
|
||||||
|
|
||||||
|
cost_chf_per_kg numeric(10, 2) not null,
|
||||||
|
|
||||||
|
-- Stock espresso in rotoli anche frazionati
|
||||||
|
stock_spools numeric(6, 3) not null default 0.000,
|
||||||
|
spool_net_kg numeric(6, 3) not null default 1.000,
|
||||||
|
|
||||||
|
is_active boolean not null default true,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
|
||||||
|
unique (filament_material_type_id, variant_display_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- (opzionale) kg disponibili calcolati
|
||||||
|
create view filament_variant_stock_kg as
|
||||||
|
select filament_variant_id,
|
||||||
|
stock_spools,
|
||||||
|
spool_net_kg,
|
||||||
|
(stock_spools * spool_net_kg) as stock_kg
|
||||||
|
from filament_variant;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
create table pricing_policy
|
||||||
|
(
|
||||||
|
pricing_policy_id bigserial primary key,
|
||||||
|
|
||||||
|
policy_name text not null, -- es: "2026 Q1", "Default", ecc.
|
||||||
|
|
||||||
|
-- validità temporale (consiglio: valid_to esclusiva)
|
||||||
|
valid_from timestamptz not null,
|
||||||
|
valid_to timestamptz,
|
||||||
|
|
||||||
|
electricity_cost_chf_per_kwh numeric(10, 6) not null,
|
||||||
|
markup_percent numeric(6, 3) not null default 20.000,
|
||||||
|
|
||||||
|
fixed_job_fee_chf numeric(10, 2) not null default 0.00, -- "costo fisso"
|
||||||
|
nozzle_change_base_fee_chf numeric(10, 2) not null default 0.00, -- base cambio ugello, se vuoi
|
||||||
|
cad_cost_chf_per_hour numeric(10, 2) not null default 0.00,
|
||||||
|
|
||||||
|
is_active boolean not null default true,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table pricing_policy_machine_hour_tier
|
||||||
|
(
|
||||||
|
pricing_policy_machine_hour_tier_id bigserial primary key,
|
||||||
|
pricing_policy_id bigint not null references pricing_policy (pricing_policy_id),
|
||||||
|
|
||||||
|
tier_start_hours numeric(10, 2) not null,
|
||||||
|
tier_end_hours numeric(10, 2), -- null = infinito
|
||||||
|
machine_cost_chf_per_hour numeric(10, 2) not null,
|
||||||
|
|
||||||
|
constraint chk_tier_start_non_negative check (tier_start_hours >= 0),
|
||||||
|
constraint chk_tier_end_gt_start check (tier_end_hours is null or tier_end_hours > tier_start_hours)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index idx_pricing_policy_validity
|
||||||
|
on pricing_policy (valid_from, valid_to);
|
||||||
|
|
||||||
|
create index idx_pricing_tier_lookup
|
||||||
|
on pricing_policy_machine_hour_tier (pricing_policy_id, tier_start_hours);
|
||||||
|
|
||||||
|
|
||||||
|
create table nozzle_option
|
||||||
|
(
|
||||||
|
nozzle_option_id bigserial primary key,
|
||||||
|
nozzle_diameter_mm numeric(4, 2) not null unique, -- 0.4, 0.6, 0.8...
|
||||||
|
|
||||||
|
owned_quantity integer not null default 0 check (owned_quantity >= 0),
|
||||||
|
|
||||||
|
-- extra costo specifico oltre ad eventuale base fee della pricing_policy
|
||||||
|
extra_nozzle_change_fee_chf numeric(10, 2) not null default 0.00,
|
||||||
|
|
||||||
|
is_active boolean not null default true,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
create table layer_height_option
|
||||||
|
(
|
||||||
|
layer_height_option_id bigserial primary key,
|
||||||
|
layer_height_mm numeric(5, 3) not null unique, -- 0.12, 0.20, 0.28...
|
||||||
|
|
||||||
|
-- opzionale: moltiplicatore costo/tempo (es: 0.12 costa di più)
|
||||||
|
time_multiplier numeric(6, 3) not null default 1.000,
|
||||||
|
|
||||||
|
is_active boolean not null default true
|
||||||
|
);
|
||||||
|
|
||||||
|
create table layer_height_profile
|
||||||
|
(
|
||||||
|
layer_height_profile_id bigserial primary key,
|
||||||
|
profile_name text not null unique, -- "Standard", "Fine", ecc.
|
||||||
|
|
||||||
|
min_layer_height_mm numeric(5, 3) not null,
|
||||||
|
max_layer_height_mm numeric(5, 3) not null,
|
||||||
|
default_layer_height_mm numeric(5, 3) not null,
|
||||||
|
|
||||||
|
time_multiplier numeric(6, 3) not null default 1.000,
|
||||||
|
|
||||||
|
constraint chk_layer_range check (max_layer_height_mm >= min_layer_height_mm)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
begin;
|
||||||
|
|
||||||
|
set timezone = 'Europe/Zurich';
|
||||||
|
|
||||||
|
is_active = excluded.is_active;
|
||||||
|
|
||||||
|
|
||||||
|
-- =========================================================
|
||||||
|
-- 1) Pricing policy (valori ESATTI da Excel)
|
||||||
|
-- Valid from: 2026-01-01, valid_to: NULL
|
||||||
|
-- =========================================================
|
||||||
|
insert into pricing_policy (
|
||||||
|
policy_name,
|
||||||
|
valid_from,
|
||||||
|
valid_to,
|
||||||
|
electricity_cost_chf_per_kwh,
|
||||||
|
markup_percent,
|
||||||
|
fixed_job_fee_chf,
|
||||||
|
nozzle_change_base_fee_chf,
|
||||||
|
cad_cost_chf_per_hour,
|
||||||
|
is_active
|
||||||
|
) values (
|
||||||
|
'Excel Tariffe 2026-01-01',
|
||||||
|
'2026-01-01 00:00:00+01'::timestamptz,
|
||||||
|
null,
|
||||||
|
0.156, -- Costo elettricità CHF/kWh (Excel)
|
||||||
|
0.000, -- Markup non specificato -> 0 (puoi cambiarlo dopo)
|
||||||
|
1.00, -- Costo fisso macchina CHF (Excel)
|
||||||
|
0.00, -- Base cambio ugello: non specificato -> 0
|
||||||
|
25.00, -- Tariffa CAD CHF/h (Excel)
|
||||||
|
true
|
||||||
|
)
|
||||||
|
on conflict do nothing;
|
||||||
|
|
||||||
|
-- scaglioni tariffa stampa (Excel)
|
||||||
|
insert into pricing_policy_machine_hour_tier (
|
||||||
|
pricing_policy_id,
|
||||||
|
tier_start_hours,
|
||||||
|
tier_end_hours,
|
||||||
|
machine_cost_chf_per_hour
|
||||||
|
)
|
||||||
|
select
|
||||||
|
p.pricing_policy_id,
|
||||||
|
tiers.tier_start_hours,
|
||||||
|
tiers.tier_end_hours,
|
||||||
|
tiers.machine_cost_chf_per_hour
|
||||||
|
from pricing_policy p
|
||||||
|
cross join (
|
||||||
|
values
|
||||||
|
(0.00::numeric, 10.00::numeric, 2.00::numeric), -- 0–10 h
|
||||||
|
(10.00::numeric, 20.00::numeric, 1.40::numeric), -- 10–20 h
|
||||||
|
(20.00::numeric, null::numeric, 0.50::numeric) -- >20 h
|
||||||
|
) as tiers(tier_start_hours, tier_end_hours, machine_cost_chf_per_hour)
|
||||||
|
where p.policy_name = 'Excel Tariffe 2026-01-01'
|
||||||
|
on conflict do nothing;
|
||||||
|
|
||||||
|
|
||||||
|
-- =========================================================
|
||||||
|
-- 2) Stampante: BambuLab A1
|
||||||
|
-- =========================================================
|
||||||
|
insert into printer_machine (
|
||||||
|
printer_display_name,
|
||||||
|
build_volume_x_mm,
|
||||||
|
build_volume_y_mm,
|
||||||
|
build_volume_z_mm,
|
||||||
|
power_watts,
|
||||||
|
fleet_weight,
|
||||||
|
is_active
|
||||||
|
) values (
|
||||||
|
'BambuLab A1',
|
||||||
|
256,
|
||||||
|
256,
|
||||||
|
256,
|
||||||
|
150, -- hai detto "150, 140": qui ho messo 150
|
||||||
|
1.000,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
on conflict (printer_display_name) do update
|
||||||
|
set
|
||||||
|
build_volume_x_mm = excluded.build_volume_x_mm,
|
||||||
|
build_volume_y_mm = excluded.build_volume_y_mm,
|
||||||
|
build_volume_z_mm = excluded.build_volume_z_mm,
|
||||||
|
power_watts = excluded.power_watts,
|
||||||
|
fleet_weight = excluded.fleet_weight,
|
||||||
|
is_active = excluded.is_active;
|
||||||
|
|
||||||
|
|
||||||
|
-- =========================================================
|
||||||
|
-- 3) Material types (da Excel) - per ora niente technical
|
||||||
|
-- =========================================================
|
||||||
|
insert into filament_material_type (
|
||||||
|
material_code,
|
||||||
|
is_flexible,
|
||||||
|
is_technical,
|
||||||
|
technical_type_label
|
||||||
|
) values
|
||||||
|
('PLA', false, false, null),
|
||||||
|
('PETG', false, false, null),
|
||||||
|
('TPU', true, false, null),
|
||||||
|
('ABS', false, false, null),
|
||||||
|
('Nylon', false, false, null),
|
||||||
|
('Carbon PLA', false, false, null)
|
||||||
|
on conflict (material_code) do update
|
||||||
|
set
|
||||||
|
is_flexible = excluded.is_flexible,
|
||||||
|
is_technical = excluded.is_technical,
|
||||||
|
technical_type_label = excluded.technical_type_label;
|
||||||
|
|
||||||
|
|
||||||
|
-- =========================================================
|
||||||
|
-- 4) Filament variants (PLA colori) - costi da Excel
|
||||||
|
-- Excel: PLA = 18 CHF/kg, TPU = 42 CHF/kg (non inserito perché quantità non chiara)
|
||||||
|
-- Stock in "rotoli" (3 = 3 kg se spool_net_kg=1)
|
||||||
|
-- =========================================================
|
||||||
|
|
||||||
|
-- helper: ID PLA
|
||||||
|
with pla as (
|
||||||
|
select filament_material_type_id
|
||||||
|
from filament_material_type
|
||||||
|
where material_code = 'PLA'
|
||||||
|
)
|
||||||
|
insert into filament_variant (
|
||||||
|
filament_material_type_id,
|
||||||
|
variant_display_name,
|
||||||
|
color_name,
|
||||||
|
is_matte,
|
||||||
|
is_special,
|
||||||
|
cost_chf_per_kg,
|
||||||
|
stock_spools,
|
||||||
|
spool_net_kg,
|
||||||
|
is_active
|
||||||
|
)
|
||||||
|
select
|
||||||
|
pla.filament_material_type_id,
|
||||||
|
v.variant_display_name,
|
||||||
|
v.color_name,
|
||||||
|
v.is_matte,
|
||||||
|
v.is_special,
|
||||||
|
18.00, -- PLA da Excel
|
||||||
|
v.stock_spools,
|
||||||
|
1.000,
|
||||||
|
true
|
||||||
|
from pla
|
||||||
|
cross join (
|
||||||
|
values
|
||||||
|
('PLA Bianco', 'Bianco', false, false, 3.000::numeric),
|
||||||
|
('PLA Nero', 'Nero', false, false, 3.000::numeric),
|
||||||
|
('PLA Blu', 'Blu', false, false, 1.000::numeric),
|
||||||
|
('PLA Arancione', 'Arancione', false, false, 1.000::numeric),
|
||||||
|
('PLA Grigio', 'Grigio', false, false, 1.000::numeric),
|
||||||
|
('PLA Grigio Scuro', 'Grigio scuro', false, false, 1.000::numeric),
|
||||||
|
('PLA Grigio Chiaro', 'Grigio chiaro', false, false, 1.000::numeric),
|
||||||
|
('PLA Viola', 'Viola', false, false, 1.000::numeric)
|
||||||
|
) as v(variant_display_name, color_name, is_matte, is_special, stock_spools)
|
||||||
|
on conflict (filament_material_type_id, variant_display_name) do update
|
||||||
|
set
|
||||||
|
color_name = excluded.color_name,
|
||||||
|
is_matte = excluded.is_matte,
|
||||||
|
is_special = excluded.is_special,
|
||||||
|
cost_chf_per_kg = excluded.cost_chf_per_kg,
|
||||||
|
stock_spools = excluded.stock_spools,
|
||||||
|
spool_net_kg = excluded.spool_net_kg,
|
||||||
|
is_active = excluded.is_active;
|
||||||
|
|
||||||
|
|
||||||
|
-- =========================================================
|
||||||
|
-- 5) Ugelli
|
||||||
|
-- 0.4 standard (0 extra), 0.6 con attivazione 50 CHF
|
||||||
|
-- =========================================================
|
||||||
|
insert into nozzle_option (
|
||||||
|
nozzle_diameter_mm,
|
||||||
|
owned_quantity,
|
||||||
|
extra_nozzle_change_fee_chf,
|
||||||
|
is_active
|
||||||
|
) values
|
||||||
|
(0.40, 1, 0.00, true),
|
||||||
|
(0.60, 1, 50.00, true)
|
||||||
|
on conflict (nozzle_diameter_mm) do update
|
||||||
|
set
|
||||||
|
owned_quantity = excluded.owned_quantity,
|
||||||
|
extra_nozzle_change_fee_chf = excluded.extra_nozzle_change_fee_chf,
|
||||||
|
is_active = excluded.is_active;
|
||||||
|
|
||||||
|
|
||||||
|
-- =========================================================
|
||||||
|
-- 6) Layer heights (opzioni)
|
||||||
|
-- =========================================================
|
||||||
|
insert into layer_height_option (
|
||||||
|
layer_height_mm,
|
||||||
|
time_multiplier,
|
||||||
|
is_active
|
||||||
|
) values
|
||||||
|
(0.080, 1.000, true),
|
||||||
|
(0.120, 1.000, true),
|
||||||
|
(0.160, 1.000, true),
|
||||||
|
(0.200, 1.000, true),
|
||||||
|
(0.240, 1.000, true),
|
||||||
|
(0.280, 1.000, true)
|
||||||
|
on conflict (layer_height_mm) do update
|
||||||
|
set
|
||||||
|
time_multiplier = excluded.time_multiplier,
|
||||||
|
is_active = excluded.is_active;
|
||||||
|
|
||||||
|
commit;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- Sostituisci __MULTIPLIER__ con il tuo valore (es. 1.10)
|
||||||
|
update layer_height_option
|
||||||
|
set time_multiplier = 0.1
|
||||||
|
where layer_height_mm = 0.080;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
REGISTRY_URL=git.joekung.ch
|
REGISTRY_URL=git.joekung.ch
|
||||||
REPO_OWNER=JoeKung
|
REPO_OWNER=joekung
|
||||||
ENV=dev
|
ENV=dev
|
||||||
TAG=dev
|
TAG=dev
|
||||||
|
|
||||||
@@ -7,9 +7,4 @@ TAG=dev
|
|||||||
BACKEND_PORT=18002
|
BACKEND_PORT=18002
|
||||||
FRONTEND_PORT=18082
|
FRONTEND_PORT=18082
|
||||||
|
|
||||||
# Application Config
|
|
||||||
FILAMENT_COST_PER_KG=22.0
|
|
||||||
MACHINE_COST_PER_HOUR=2.50
|
|
||||||
ENERGY_COST_PER_KWH=0.30
|
|
||||||
PRINTER_POWER_WATTS=150
|
|
||||||
MARKUP_PERCENT=20
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
REGISTRY_URL=git.joekung.ch
|
REGISTRY_URL=git.joekung.ch
|
||||||
REPO_OWNER=JoeKung
|
REPO_OWNER=joekung
|
||||||
ENV=int
|
ENV=int
|
||||||
TAG=int
|
TAG=int
|
||||||
|
|
||||||
@@ -7,9 +7,4 @@ TAG=int
|
|||||||
BACKEND_PORT=18001
|
BACKEND_PORT=18001
|
||||||
FRONTEND_PORT=18081
|
FRONTEND_PORT=18081
|
||||||
|
|
||||||
# Application Config
|
|
||||||
FILAMENT_COST_PER_KG=22.0
|
|
||||||
MACHINE_COST_PER_HOUR=2.50
|
|
||||||
ENERGY_COST_PER_KWH=0.30
|
|
||||||
PRINTER_POWER_WATTS=150
|
|
||||||
MARKUP_PERCENT=20
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
REGISTRY_URL=git.joekung.ch
|
REGISTRY_URL=git.joekung.ch
|
||||||
REPO_OWNER=JoeKung
|
REPO_OWNER=joekung
|
||||||
ENV=prod
|
ENV=prod
|
||||||
TAG=prod
|
TAG=prod
|
||||||
|
|
||||||
@@ -7,9 +7,4 @@ TAG=prod
|
|||||||
BACKEND_PORT=8000
|
BACKEND_PORT=8000
|
||||||
FRONTEND_PORT=80
|
FRONTEND_PORT=80
|
||||||
|
|
||||||
# Application Config
|
|
||||||
FILAMENT_COST_PER_KG=22.0
|
|
||||||
MACHINE_COST_PER_HOUR=2.50
|
|
||||||
ENERGY_COST_PER_KWH=0.30
|
|
||||||
PRINTER_POWER_WATTS=150
|
|
||||||
MARKUP_PERCENT=20
|
|
||||||
|
|||||||
@@ -7,26 +7,27 @@ services:
|
|||||||
container_name: print-calculator-backend-${ENV}
|
container_name: print-calculator-backend-${ENV}
|
||||||
ports:
|
ports:
|
||||||
- "${BACKEND_PORT}:8000"
|
- "${BACKEND_PORT}:8000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- FILAMENT_COST_PER_KG=${FILAMENT_COST_PER_KG}
|
- DB_URL=${DB_URL}
|
||||||
- MACHINE_COST_PER_HOUR=${MACHINE_COST_PER_HOUR}
|
- DB_USERNAME=${DB_USERNAME}
|
||||||
- ENERGY_COST_PER_KWH=${ENERGY_COST_PER_KWH}
|
- DB_PASSWORD=${DB_PASSWORD}
|
||||||
- PRINTER_POWER_WATTS=${PRINTER_POWER_WATTS}
|
|
||||||
- MARKUP_PERCENT=${MARKUP_PERCENT}
|
|
||||||
- TEMP_DIR=/app/temp
|
- TEMP_DIR=/app/temp
|
||||||
- PROFILES_DIR=/app/profiles
|
- PROFILES_DIR=/app/profiles
|
||||||
restart: unless-stopped
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- backend_profiles_${ENV}:/app/profiles
|
- backend_profiles_${ENV}:/app/profiles
|
||||||
|
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: ${REGISTRY_URL}/${REPO_OWNER}/print-calculator-frontend:${TAG}
|
image: ${REGISTRY_URL}/${REPO_OWNER}/print-calculator-frontend:${TAG}
|
||||||
container_name: print-calculator-frontend-${ENV}
|
container_name: print-calculator-frontend-${ENV}
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT}:8008"
|
- "${FRONTEND_PORT}:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
restart: unless-stopped
|
restart: always
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
backend_profiles_prod:
|
backend_profiles_prod:
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
environment:
|
environment:
|
||||||
|
- DB_URL=jdbc:postgresql://db:5432/printcalc
|
||||||
|
- DB_USERNAME=printcalc
|
||||||
|
- DB_PASSWORD=printcalc_secret
|
||||||
|
- SPRING_PROFILES_ACTIVE=local
|
||||||
- FILAMENT_COST_PER_KG=22.0
|
- FILAMENT_COST_PER_KG=22.0
|
||||||
- MACHINE_COST_PER_HOUR=2.50
|
- MACHINE_COST_PER_HOUR=2.50
|
||||||
- ENERGY_COST_PER_KWH=0.30
|
- ENERGY_COST_PER_KWH=0.30
|
||||||
@@ -16,13 +20,34 @@ services:
|
|||||||
- MARKUP_PERCENT=20
|
- MARKUP_PERCENT=20
|
||||||
- TEMP_DIR=/app/temp
|
- TEMP_DIR=/app/temp
|
||||||
- PROFILES_DIR=/app/profiles
|
- PROFILES_DIR=/app/profiles
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build: ./frontend
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile.dev
|
||||||
container_name: print-calculator-frontend
|
container_name: print-calculator-frontend
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
- db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: print-calculator-db
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=printcalc
|
||||||
|
- POSTGRES_PASSWORD=printcalc_secret
|
||||||
|
- POSTGRES_DB=printcalc
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
|||||||
15
frontend/Dockerfile.dev
Normal file
15
frontend/Dockerfile.dev
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Stage 1: Build
|
||||||
|
FROM node:20 as build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
# Use development configuration to pick up environment.ts (localhost)
|
||||||
|
RUN npm run build -- --configuration=development
|
||||||
|
|
||||||
|
# Stage 2: Serve
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/dist/frontend/browser /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
@@ -70,6 +70,17 @@
|
|||||||
"optimization": false,
|
"optimization": false,
|
||||||
"extractLicenses": false,
|
"extractLicenses": false,
|
||||||
"sourceMap": true
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"local": {
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.local.ts"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -83,6 +94,9 @@
|
|||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
"buildTarget": "frontend:build:development"
|
"buildTarget": "frontend:build:development"
|
||||||
|
},
|
||||||
|
"local": {
|
||||||
|
"buildTarget": "frontend:build:local"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"defaultConfiguration": "development"
|
"defaultConfiguration": "development"
|
||||||
|
|||||||
0
frontend/src/app/app.component.scss
Normal file
0
frontend/src/app/app.component.scss
Normal file
@@ -5,6 +5,7 @@ import { RouterOutlet } from '@angular/router';
|
|||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterOutlet],
|
imports: [RouterOutlet],
|
||||||
template: `<router-outlet></router-outlet>`
|
templateUrl: './app.component.html',
|
||||||
|
styleUrl: './app.component.scss'
|
||||||
})
|
})
|
||||||
export class AppComponent {}
|
export class AppComponent {}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const routes: Routes = [
|
|||||||
loadComponent: () => import('./features/home/home.component').then(m => m.HomeComponent)
|
loadComponent: () => import('./features/home/home.component').then(m => m.HomeComponent)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'cal',
|
path: 'calculator',
|
||||||
loadChildren: () => import('./features/calculator/calculator.routes').then(m => m.CALCULATOR_ROUTES)
|
loadChildren: () => import('./features/calculator/calculator.routes').then(m => m.CALCULATOR_ROUTES)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -24,6 +24,10 @@ export const routes: Routes = [
|
|||||||
{
|
{
|
||||||
path: 'contact',
|
path: 'contact',
|
||||||
loadChildren: () => import('./features/contact/contact.routes').then(m => m.CONTACT_ROUTES)
|
loadChildren: () => import('./features/contact/contact.routes').then(m => m.CONTACT_ROUTES)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
loadChildren: () => import('./features/legal/legal.routes').then(m => m.LEGAL_ROUTES)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
41
frontend/src/app/core/constants/colors.const.ts
Normal file
41
frontend/src/app/core/constants/colors.const.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export interface ColorOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
hex: string;
|
||||||
|
outOfStock?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColorCategory {
|
||||||
|
name: string; // 'Glossy' | 'Matte'
|
||||||
|
colors: ColorOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PRODUCT_COLORS: ColorCategory[] = [
|
||||||
|
{
|
||||||
|
name: 'Lucidi', // Glossy
|
||||||
|
colors: [
|
||||||
|
{ label: 'Black', value: 'Black', hex: '#1a1a1a' }, // Not pure black for visibility
|
||||||
|
{ label: 'White', value: 'White', hex: '#f5f5f5' },
|
||||||
|
{ label: 'Red', value: 'Red', hex: '#d32f2f', outOfStock: true },
|
||||||
|
{ label: 'Blue', value: 'Blue', hex: '#1976d2' },
|
||||||
|
{ label: 'Green', value: 'Green', hex: '#388e3c' },
|
||||||
|
{ label: 'Yellow', value: 'Yellow', hex: '#fbc02d' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Opachi', // Matte
|
||||||
|
colors: [
|
||||||
|
{ label: 'Matte Black', value: 'Matte Black', hex: '#2c2c2c' }, // Lighter charcoal for matte
|
||||||
|
{ label: 'Matte White', value: 'Matte White', hex: '#e0e0e0' },
|
||||||
|
{ label: 'Matte Gray', value: 'Matte Gray', hex: '#757575' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getColorHex(value: string): string {
|
||||||
|
for (const cat of PRODUCT_COLORS) {
|
||||||
|
const found = cat.colors.find(c => c.value === value);
|
||||||
|
if (found) return found.hex;
|
||||||
|
}
|
||||||
|
return '#facf0a'; // Default Brand Color if not found
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
<div class="col links">
|
<div class="col links">
|
||||||
<a routerLink="/privacy">{{ 'FOOTER.PRIVACY' | translate }}</a>
|
<a routerLink="/privacy">{{ 'FOOTER.PRIVACY' | translate }}</a>
|
||||||
<a routerLink="/terms">{{ 'FOOTER.TERMS' | translate }}</a>
|
<a routerLink="/terms">{{ 'FOOTER.TERMS' | translate }}</a>
|
||||||
<a routerLink="/about">{{ 'FOOTER.CONTACT' | translate }}</a>
|
<a routerLink="/contact">{{ 'FOOTER.CONTACT' | translate }}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col social">
|
<div class="col social">
|
||||||
|
|||||||
7
frontend/src/app/core/layout/layout.component.html
Normal file
7
frontend/src/app/core/layout/layout.component.html
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<div class="layout-wrapper">
|
||||||
|
<app-navbar></app-navbar>
|
||||||
|
<main class="main-content">
|
||||||
|
<router-outlet></router-outlet>
|
||||||
|
</main>
|
||||||
|
<app-footer></app-footer>
|
||||||
|
</div>
|
||||||
9
frontend/src/app/core/layout/layout.component.scss
Normal file
9
frontend/src/app/core/layout/layout.component.scss
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.layout-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
padding-bottom: var(--space-12);
|
||||||
|
}
|
||||||
@@ -7,25 +7,7 @@ import { FooterComponent } from './footer.component';
|
|||||||
selector: 'app-layout',
|
selector: 'app-layout',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterOutlet, NavbarComponent, FooterComponent],
|
imports: [RouterOutlet, NavbarComponent, FooterComponent],
|
||||||
template: `
|
templateUrl: './layout.component.html',
|
||||||
<div class="layout-wrapper">
|
styleUrl: './layout.component.scss'
|
||||||
<app-navbar></app-navbar>
|
|
||||||
<main class="main-content">
|
|
||||||
<router-outlet></router-outlet>
|
|
||||||
</main>
|
|
||||||
<app-footer></app-footer>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
styles: [`
|
|
||||||
.layout-wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
.main-content {
|
|
||||||
flex: 1;
|
|
||||||
padding-bottom: var(--space-12);
|
|
||||||
}
|
|
||||||
`]
|
|
||||||
})
|
})
|
||||||
export class LayoutComponent {}
|
export class LayoutComponent {}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
<nav class="nav-links" [class.open]="isMenuOpen">
|
<nav class="nav-links" [class.open]="isMenuOpen">
|
||||||
<a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" (click)="closeMenu()">{{ 'NAV.HOME' | translate }}</a>
|
<a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" (click)="closeMenu()">{{ 'NAV.HOME' | translate }}</a>
|
||||||
<a routerLink="/cal" routerLinkActive="active" [routerLinkActiveOptions]="{exact: false}" (click)="closeMenu()">{{ 'NAV.CALCULATOR' | translate }}</a>
|
<a routerLink="/calculator/basic" routerLinkActive="active" [routerLinkActiveOptions]="{exact: false}" (click)="closeMenu()">{{ 'NAV.CALCULATOR' | translate }}</a>
|
||||||
<a routerLink="/shop" routerLinkActive="active" (click)="closeMenu()">{{ 'NAV.SHOP' | translate }}</a>
|
<a routerLink="/shop" routerLinkActive="active" (click)="closeMenu()">{{ 'NAV.SHOP' | translate }}</a>
|
||||||
<a routerLink="/about" routerLinkActive="active" (click)="closeMenu()">{{ 'NAV.ABOUT' | translate }}</a>
|
<a routerLink="/about" routerLinkActive="active" (click)="closeMenu()">{{ 'NAV.ABOUT' | translate }}</a>
|
||||||
<a routerLink="/contact" routerLinkActive="active" (click)="closeMenu()">{{ 'NAV.CONTACT' | translate }}</a>
|
<a routerLink="/contact" routerLinkActive="active" (click)="closeMenu()">{{ 'NAV.CONTACT' | translate }}</a>
|
||||||
|
|||||||
44
frontend/src/app/features/about/about-page.component.html
Normal file
44
frontend/src/app/features/about/about-page.component.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<section class="about-section">
|
||||||
|
<div class="container split-layout">
|
||||||
|
|
||||||
|
<!-- Left Column: Content -->
|
||||||
|
<div class="text-content">
|
||||||
|
<p class="eyebrow">{{ 'ABOUT.EYEBROW' | translate }}</p>
|
||||||
|
<h1>{{ 'ABOUT.TITLE' | translate }}</h1>
|
||||||
|
<p class="subtitle">{{ 'ABOUT.SUBTITLE' | translate }}</p>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<p class="description">{{ 'ABOUT.HOW_TEXT' | translate }}</p>
|
||||||
|
|
||||||
|
<div class="tags-container">
|
||||||
|
<span class="tag">{{ 'ABOUT.PILL_1' | translate }}</span>
|
||||||
|
<span class="tag">{{ 'ABOUT.PILL_2' | translate }}</span>
|
||||||
|
<span class="tag">{{ 'ABOUT.PILL_3' | translate }}</span>
|
||||||
|
<span class="tag">{{ 'ABOUT.SERVICE_1' | translate }}</span>
|
||||||
|
<span class="tag">{{ 'ABOUT.SERVICE_2' | translate }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column: Visuals -->
|
||||||
|
<div class="visual-content">
|
||||||
|
<div class="photo-card card-1">
|
||||||
|
<div class="placeholder-img"></div>
|
||||||
|
<div class="member-info">
|
||||||
|
<span class="member-name">Member 1</span>
|
||||||
|
<span class="member-role">Founder</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="photo-card card-2">
|
||||||
|
<div class="placeholder-img"></div>
|
||||||
|
<div class="member-info">
|
||||||
|
<span class="member-name">Member 2</span>
|
||||||
|
<span class="member-role">Co-Founder</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<app-locations></app-locations>
|
||||||
157
frontend/src/app/features/about/about-page.component.scss
Normal file
157
frontend/src/app/features/about/about-page.component.scss
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
.about-section {
|
||||||
|
padding: 6rem 0;
|
||||||
|
background: var(--color-bg);
|
||||||
|
min-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 4rem;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center; /* Center on mobile */
|
||||||
|
|
||||||
|
@media(min-width: 992px) {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 6rem;
|
||||||
|
text-align: left; /* Reset to left on desktop */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Left Column */
|
||||||
|
.text-content {
|
||||||
|
/* text-align: left; Removed to inherit from parent */
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-primary-500);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 3rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
color: var(--color-text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 4px;
|
||||||
|
width: 60px;
|
||||||
|
background: var(--color-primary-500);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
/* Center divider on mobile */
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
|
||||||
|
@media(min-width: 992px) {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--color-text-main);
|
||||||
|
margin-bottom: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: center; /* Center tags on mobile */
|
||||||
|
|
||||||
|
@media(min-width: 992px) {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: var(--color-surface-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-main);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: var(--color-primary-500);
|
||||||
|
color: var(--color-primary-500);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right Column */
|
||||||
|
.visual-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
|
||||||
|
@media(min-width: 768px) {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-card {
|
||||||
|
background: var(--color-surface-card);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 260px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-img {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 3/4;
|
||||||
|
background: linear-gradient(45deg, var(--color-neutral-200), var(--color-neutral-100));
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-info {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-name {
|
||||||
|
display: block;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-main);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-role {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
@@ -1,214 +1,13 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { AppLocationsComponent } from '../../shared/components/app-locations/app-locations.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-about-page',
|
selector: 'app-about-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [TranslateModule],
|
imports: [TranslateModule, AppLocationsComponent],
|
||||||
template: `
|
templateUrl: './about-page.component.html',
|
||||||
<section class="about-section">
|
styleUrl: './about-page.component.scss'
|
||||||
<div class="container split-layout">
|
|
||||||
|
|
||||||
<!-- Left Column: Content -->
|
|
||||||
<div class="text-content">
|
|
||||||
<p class="eyebrow">{{ 'ABOUT.EYEBROW' | translate }}</p>
|
|
||||||
<h1>{{ 'ABOUT.TITLE' | translate }}</h1>
|
|
||||||
<p class="subtitle">{{ 'ABOUT.SUBTITLE' | translate }}</p>
|
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<p class="description">{{ 'ABOUT.HOW_TEXT' | translate }}</p>
|
|
||||||
|
|
||||||
<div class="tags-container">
|
|
||||||
<span class="tag">{{ 'ABOUT.PILL_1' | translate }}</span>
|
|
||||||
<span class="tag">{{ 'ABOUT.PILL_2' | translate }}</span>
|
|
||||||
<span class="tag">{{ 'ABOUT.PILL_3' | translate }}</span>
|
|
||||||
<span class="tag">{{ 'ABOUT.SERVICE_1' | translate }}</span>
|
|
||||||
<span class="tag">{{ 'ABOUT.SERVICE_2' | translate }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right Column: Visuals -->
|
|
||||||
<div class="visual-content">
|
|
||||||
<div class="photo-card card-1">
|
|
||||||
<div class="placeholder-img"></div>
|
|
||||||
<div class="member-info">
|
|
||||||
<span class="member-name">Member 1</span>
|
|
||||||
<span class="member-role">Founder</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="photo-card card-2">
|
|
||||||
<div class="placeholder-img"></div>
|
|
||||||
<div class="member-info">
|
|
||||||
<span class="member-name">Member 2</span>
|
|
||||||
<span class="member-role">Co-Founder</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
`,
|
|
||||||
styles: [`
|
|
||||||
.about-section {
|
|
||||||
padding: 6rem 0;
|
|
||||||
background: var(--color-bg);
|
|
||||||
min-height: 80vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.split-layout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 4rem;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center; /* Center on mobile */
|
|
||||||
|
|
||||||
@media(min-width: 992px) {
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 6rem;
|
|
||||||
text-align: left; /* Reset to left on desktop */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Left Column */
|
|
||||||
.text-content {
|
|
||||||
/* text-align: left; Removed to inherit from parent */
|
|
||||||
}
|
|
||||||
|
|
||||||
.eyebrow {
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.15em;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--color-primary-500);
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 3rem;
|
|
||||||
line-height: 1.1;
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
color: var(--color-text-main);
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
margin-bottom: var(--space-6);
|
|
||||||
font-weight: 300;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
height: 4px;
|
|
||||||
width: 60px;
|
|
||||||
background: var(--color-primary-500);
|
|
||||||
border-radius: 2px;
|
|
||||||
margin-bottom: var(--space-6);
|
|
||||||
/* Center divider on mobile */
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
|
|
||||||
@media(min-width: 992px) {
|
|
||||||
margin-left: 0;
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.description {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
line-height: 1.7;
|
|
||||||
color: var(--color-text-main);
|
|
||||||
margin-bottom: var(--space-8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags-container {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.75rem;
|
|
||||||
justify-content: center; /* Center tags on mobile */
|
|
||||||
|
|
||||||
@media(min-width: 992px) {
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 99px;
|
|
||||||
background: var(--color-surface-card);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
color: var(--color-text-main);
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
border-color: var(--color-primary-500);
|
|
||||||
color: var(--color-primary-500);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Right Column */
|
|
||||||
.visual-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2rem;
|
|
||||||
|
|
||||||
@media(min-width: 768px) {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
align-items: start;
|
|
||||||
justify-items: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.photo-card {
|
|
||||||
background: var(--color-surface-card);
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
width: 100%;
|
|
||||||
max-width: 260px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-img {
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 3/4;
|
|
||||||
background: linear-gradient(45deg, var(--color-neutral-200), var(--color-neutral-100));
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-info {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-name {
|
|
||||||
display: block;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-text-main);
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-role {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
`]
|
|
||||||
})
|
})
|
||||||
export class AboutPageComponent {}
|
export class AboutPageComponent {}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<div class="container hero">
|
||||||
|
<h1>{{ 'CALC.TITLE' | translate }}</h1>
|
||||||
|
<p class="subtitle">{{ 'CALC.SUBTITLE' | translate }}</p>
|
||||||
|
|
||||||
|
@if (error()) {
|
||||||
|
<app-alert type="error">{{ 'CALC.ERROR_GENERIC' | translate }}</app-alert>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (step() === 'success') {
|
||||||
|
<div class="container hero">
|
||||||
|
<app-success-state context="calc" (action)="onNewQuote()"></app-success-state>
|
||||||
|
</div>
|
||||||
|
} @else if (step() === 'details' && result()) {
|
||||||
|
<div class="container">
|
||||||
|
<app-user-details
|
||||||
|
[quote]="result()!"
|
||||||
|
(submitOrder)="onSubmitOrder($event)"
|
||||||
|
(cancel)="onCancelDetails()">
|
||||||
|
</app-user-details>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="container content-grid">
|
||||||
|
<!-- Left Column: Input -->
|
||||||
|
<div class="col-input">
|
||||||
|
<app-card>
|
||||||
|
<div class="mode-selector">
|
||||||
|
<div class="mode-option"
|
||||||
|
[class.active]="mode() === 'easy'"
|
||||||
|
(click)="mode.set('easy')">
|
||||||
|
{{ 'CALC.MODE_EASY' | translate }}
|
||||||
|
</div>
|
||||||
|
<div class="mode-option"
|
||||||
|
[class.active]="mode() === 'advanced'"
|
||||||
|
(click)="mode.set('advanced')">
|
||||||
|
{{ 'CALC.MODE_ADVANCED' | translate }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-upload-form
|
||||||
|
#uploadForm
|
||||||
|
[mode]="mode()"
|
||||||
|
[loading]="loading()"
|
||||||
|
[uploadProgress]="uploadProgress()"
|
||||||
|
(submitRequest)="onCalculate($event)"
|
||||||
|
></app-upload-form>
|
||||||
|
</app-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column: Result or Info -->
|
||||||
|
<div class="col-result" #resultCol>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<app-card class="loading-state">
|
||||||
|
<div class="loader-content">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<h3 class="loading-title">Analisi in corso...</h3>
|
||||||
|
<p class="loading-text">Stiamo analizzando la geometria e calcolando il percorso utensile.</p>
|
||||||
|
</div>
|
||||||
|
</app-card>
|
||||||
|
} @else if (result()) {
|
||||||
|
<app-quote-result
|
||||||
|
[result]="result()!"
|
||||||
|
(consult)="onConsult()"
|
||||||
|
(proceed)="onProceed()"
|
||||||
|
(itemChange)="uploadForm.updateItemQuantityByName($event.fileName, $event.quantity)"
|
||||||
|
></app-quote-result>
|
||||||
|
} @else {
|
||||||
|
<app-card>
|
||||||
|
<h3>{{ 'CALC.BENEFITS_TITLE' | translate }}</h3>
|
||||||
|
<ul class="benefits">
|
||||||
|
<li>{{ 'CALC.BENEFITS_1' | translate }}</li>
|
||||||
|
<li>{{ 'CALC.BENEFITS_2' | translate }}</li>
|
||||||
|
<li>{{ 'CALC.BENEFITS_3' | translate }}</li>
|
||||||
|
</ul>
|
||||||
|
</app-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
.hero { padding: var(--space-12) 0; text-align: center; }
|
||||||
|
.subtitle { font-size: 1.25rem; color: var(--color-text-muted); max-width: 600px; margin: 0 auto; }
|
||||||
|
|
||||||
|
.content-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-6);
|
||||||
|
@media(min-width: 768px) {
|
||||||
|
grid-template-columns: 1.5fr 1fr;
|
||||||
|
gap: var(--space-8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.centered-col {
|
||||||
|
align-self: flex-start; /* Default */
|
||||||
|
@media(min-width: 768px) {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-input {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-result {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stretch only the loading card so the spinner stays centered */
|
||||||
|
.col-result > .loading-state {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mode Selector (Segmented Control style) */
|
||||||
|
.mode-selector {
|
||||||
|
display: flex;
|
||||||
|
background-color: var(--color-neutral-100);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 4px;
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-option {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:hover { color: var(--color-text); }
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background-color: var(--color-brand);
|
||||||
|
color: #000;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.benefits { padding-left: var(--space-4); color: var(--color-text-muted); line-height: 2; }
|
||||||
|
|
||||||
|
|
||||||
|
.loader-content {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 300px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
/* Center content vertically within the stretched card */
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: var(--space-4) 0 var(--space-2);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
border: 3px solid var(--color-neutral-200);
|
||||||
|
border-left-color: var(--color-brand);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, signal } from '@angular/core';
|
import { Component, signal, ViewChild, ElementRef, OnInit } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
|
||||||
@@ -6,157 +6,73 @@ import { AppCardComponent } from '../../shared/components/app-card/app-card.comp
|
|||||||
import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component';
|
import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component';
|
||||||
import { UploadFormComponent } from './components/upload-form/upload-form.component';
|
import { UploadFormComponent } from './components/upload-form/upload-form.component';
|
||||||
import { QuoteResultComponent } from './components/quote-result/quote-result.component';
|
import { QuoteResultComponent } from './components/quote-result/quote-result.component';
|
||||||
import { QuoteEstimatorService, QuoteRequest, QuoteResult } from './services/quote-estimator.service';
|
import { QuoteRequest, QuoteResult, QuoteEstimatorService } from './services/quote-estimator.service';
|
||||||
|
import { UserDetailsComponent } from './components/user-details/user-details.component';
|
||||||
|
import { SuccessStateComponent } from '../../shared/components/success-state/success-state.component';
|
||||||
|
import { Router, ActivatedRoute } from '@angular/router';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-calculator-page',
|
selector: 'app-calculator-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, TranslateModule, AppCardComponent, AppAlertComponent, UploadFormComponent, QuoteResultComponent],
|
imports: [CommonModule, TranslateModule, AppCardComponent, AppAlertComponent, UploadFormComponent, QuoteResultComponent, UserDetailsComponent, SuccessStateComponent],
|
||||||
template: `
|
templateUrl: './calculator-page.component.html',
|
||||||
<div class="container hero">
|
styleUrl: './calculator-page.component.scss'
|
||||||
<h1>{{ 'CALC.TITLE' | translate }}</h1>
|
|
||||||
<p class="subtitle">{{ 'CALC.SUBTITLE' | translate }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="container content-grid">
|
|
||||||
<!-- Left Column: Input -->
|
|
||||||
<div class="col-input">
|
|
||||||
<app-card>
|
|
||||||
<div class="mode-selector">
|
|
||||||
<div class="mode-option"
|
|
||||||
[class.active]="mode() === 'easy'"
|
|
||||||
(click)="mode.set('easy')">
|
|
||||||
{{ 'CALC.MODE_EASY' | translate }}
|
|
||||||
</div>
|
|
||||||
<div class="mode-option"
|
|
||||||
[class.active]="mode() === 'advanced'"
|
|
||||||
(click)="mode.set('advanced')">
|
|
||||||
{{ 'CALC.MODE_ADVANCED' | translate }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<app-upload-form
|
|
||||||
[mode]="mode()"
|
|
||||||
[loading]="loading()"
|
|
||||||
(submitRequest)="onCalculate($event)"
|
|
||||||
></app-upload-form>
|
|
||||||
</app-card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right Column: Result or Info -->
|
|
||||||
<div class="col-result">
|
|
||||||
@if (error()) {
|
|
||||||
<app-alert type="error">Si è verificato un errore durante il calcolo del preventivo.</app-alert>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (loading()) {
|
|
||||||
<app-card class="loading-state">
|
|
||||||
<div class="spinner"></div>
|
|
||||||
<p>Analisi geometria e slicing in corso...</p>
|
|
||||||
<small class="text-muted">Potrebbe richiedere qualche secondo.</small>
|
|
||||||
</app-card>
|
|
||||||
} @else if (result()) {
|
|
||||||
<app-quote-result [result]="result()!"></app-quote-result>
|
|
||||||
} @else {
|
|
||||||
<app-card>
|
|
||||||
<h3>{{ 'CALC.BENEFITS_TITLE' | translate }}</h3>
|
|
||||||
<ul class="benefits">
|
|
||||||
<li>{{ 'CALC.BENEFITS_1' | translate }}</li>
|
|
||||||
<li>{{ 'CALC.BENEFITS_2' | translate }}</li>
|
|
||||||
<li>{{ 'CALC.BENEFITS_3' | translate }}</li>
|
|
||||||
</ul>
|
|
||||||
</app-card>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
styles: [`
|
|
||||||
.hero { padding: var(--space-12) 0; text-align: center; }
|
|
||||||
.subtitle { font-size: 1.25rem; color: var(--color-text-muted); max-width: 600px; margin: 0 auto; }
|
|
||||||
|
|
||||||
.content-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: var(--space-8);
|
|
||||||
@media(min-width: 768px) {
|
|
||||||
grid-template-columns: 1.5fr 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mode Selector (Segmented Control style) */
|
|
||||||
.mode-selector {
|
|
||||||
display: flex;
|
|
||||||
background-color: var(--color-neutral-100);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 4px;
|
|
||||||
margin-bottom: var(--space-6);
|
|
||||||
gap: 4px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-option {
|
|
||||||
flex: 1;
|
|
||||||
text-align: center;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
&:hover { color: var(--color-text); }
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background-color: var(--color-brand);
|
|
||||||
color: #000;
|
|
||||||
font-weight: 600;
|
|
||||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.benefits { padding-left: var(--space-4); color: var(--color-text-muted); line-height: 2; }
|
|
||||||
|
|
||||||
.loading-state {
|
|
||||||
text-align: center;
|
|
||||||
padding: var(--space-8);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
border: 3px solid rgba(0, 0, 0, 0.1);
|
|
||||||
border-left-color: var(--color-brand);
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
margin: 0 auto var(--space-4);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
0% { transform: rotate(0deg); }
|
|
||||||
100% { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
`]
|
|
||||||
})
|
})
|
||||||
export class CalculatorPageComponent {
|
export class CalculatorPageComponent implements OnInit {
|
||||||
mode = signal<any>('easy');
|
mode = signal<any>('easy');
|
||||||
|
step = signal<'upload' | 'quote' | 'details' | 'success'>('upload');
|
||||||
|
|
||||||
loading = signal(false);
|
loading = signal(false);
|
||||||
|
uploadProgress = signal(0);
|
||||||
result = signal<QuoteResult | null>(null);
|
result = signal<QuoteResult | null>(null);
|
||||||
error = signal<boolean>(false);
|
error = signal<boolean>(false);
|
||||||
|
|
||||||
constructor(private estimator: QuoteEstimatorService) {}
|
orderSuccess = signal(false);
|
||||||
|
|
||||||
|
@ViewChild('uploadForm') uploadForm!: UploadFormComponent;
|
||||||
|
@ViewChild('resultCol') resultCol!: ElementRef;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private estimator: QuoteEstimatorService,
|
||||||
|
private router: Router,
|
||||||
|
private route: ActivatedRoute
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.route.data.subscribe(data => {
|
||||||
|
if (data['mode']) {
|
||||||
|
this.mode.set(data['mode']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
onCalculate(req: QuoteRequest) {
|
onCalculate(req: QuoteRequest) {
|
||||||
|
// ... (logic remains the same, simplified for diff)
|
||||||
|
this.currentRequest = req;
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
|
this.uploadProgress.set(0);
|
||||||
this.error.set(false);
|
this.error.set(false);
|
||||||
this.result.set(null);
|
this.result.set(null);
|
||||||
|
this.orderSuccess.set(false);
|
||||||
|
|
||||||
|
// Auto-scroll on mobile to make analysis visible
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.resultCol && window.innerWidth < 768) {
|
||||||
|
this.resultCol.nativeElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
this.estimator.calculate(req).subscribe({
|
this.estimator.calculate(req).subscribe({
|
||||||
next: (res) => {
|
next: (event) => {
|
||||||
this.result.set(res);
|
if (typeof event === 'number') {
|
||||||
this.loading.set(false);
|
this.uploadProgress.set(event);
|
||||||
|
} else {
|
||||||
|
// It's the result
|
||||||
|
this.result.set(event as QuoteResult);
|
||||||
|
this.loading.set(false);
|
||||||
|
this.uploadProgress.set(100);
|
||||||
|
this.step.set('quote');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.error.set(true);
|
this.error.set(true);
|
||||||
@@ -164,4 +80,58 @@ export class CalculatorPageComponent {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onProceed() {
|
||||||
|
this.step.set('details');
|
||||||
|
}
|
||||||
|
|
||||||
|
onCancelDetails() {
|
||||||
|
this.step.set('quote');
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmitOrder(orderData: any) {
|
||||||
|
console.log('Order Submitted:', orderData);
|
||||||
|
this.orderSuccess.set(true);
|
||||||
|
this.step.set('success');
|
||||||
|
}
|
||||||
|
|
||||||
|
onNewQuote() {
|
||||||
|
this.step.set('upload');
|
||||||
|
this.result.set(null);
|
||||||
|
this.orderSuccess.set(false);
|
||||||
|
this.mode.set('easy'); // Reset to default
|
||||||
|
}
|
||||||
|
|
||||||
|
private currentRequest: QuoteRequest | null = null;
|
||||||
|
|
||||||
|
onConsult() {
|
||||||
|
if (!this.currentRequest) return;
|
||||||
|
|
||||||
|
const req = this.currentRequest;
|
||||||
|
let details = `Richiesta Preventivo:\n`;
|
||||||
|
details += `- Materiale: ${req.material}\n`;
|
||||||
|
details += `- Qualità: ${req.quality}\n`;
|
||||||
|
|
||||||
|
details += `- File:\n`;
|
||||||
|
req.items.forEach(item => {
|
||||||
|
details += ` * ${item.file.name} (Qtà: ${item.quantity}`;
|
||||||
|
if (item.color) {
|
||||||
|
details += `, Colore: ${item.color}`;
|
||||||
|
}
|
||||||
|
details += `)\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.mode === 'advanced') {
|
||||||
|
if (req.infillDensity) details += `- Infill: ${req.infillDensity}%\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.notes) details += `\nNote: ${req.notes}`;
|
||||||
|
|
||||||
|
this.estimator.setPendingConsultation({
|
||||||
|
files: req.items.map(i => i.file),
|
||||||
|
message: details
|
||||||
|
});
|
||||||
|
|
||||||
|
this.router.navigate(['/contact']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,5 +2,7 @@ import { Routes } from '@angular/router';
|
|||||||
import { CalculatorPageComponent } from './calculator-page.component';
|
import { CalculatorPageComponent } from './calculator-page.component';
|
||||||
|
|
||||||
export const CALCULATOR_ROUTES: Routes = [
|
export const CALCULATOR_ROUTES: Routes = [
|
||||||
{ path: '', component: CalculatorPageComponent }
|
{ path: '', redirectTo: 'basic', pathMatch: 'full' },
|
||||||
|
{ path: 'basic', component: CalculatorPageComponent, data: { mode: 'easy' } },
|
||||||
|
{ path: 'advanced', component: CalculatorPageComponent, data: { mode: 'advanced' } }
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<app-card>
|
||||||
|
<h3 class="title">{{ 'CALC.RESULT' | translate }}</h3>
|
||||||
|
|
||||||
|
<!-- Summary Grid (NOW ON TOP) -->
|
||||||
|
<div class="result-grid">
|
||||||
|
<app-summary-card
|
||||||
|
class="item full-width"
|
||||||
|
[label]="'CALC.COST' | translate"
|
||||||
|
[large]="true"
|
||||||
|
[highlight]="true">
|
||||||
|
{{ totals().price | currency:result().currency }}
|
||||||
|
</app-summary-card>
|
||||||
|
|
||||||
|
<app-summary-card [label]="'CALC.TIME' | translate">
|
||||||
|
{{ totals().hours }}h {{ totals().minutes }}m
|
||||||
|
</app-summary-card>
|
||||||
|
|
||||||
|
<app-summary-card [label]="'CALC.MATERIAL' | translate">
|
||||||
|
{{ totals().weight }}g
|
||||||
|
</app-summary-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setup-note">
|
||||||
|
<small>* Include {{ result().setupCost | currency:result().currency }} Setup Cost</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (result().notes) {
|
||||||
|
<div class="notes-section">
|
||||||
|
<label>{{ 'CALC.NOTES' | translate }}:</label>
|
||||||
|
<p>{{ result().notes }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<!-- Detailed Items List (NOW ON BOTTOM) -->
|
||||||
|
<div class="items-list">
|
||||||
|
@for (item of items(); track item.fileName; let i = $index) {
|
||||||
|
<div class="item-row">
|
||||||
|
<div class="item-info">
|
||||||
|
<span class="file-name">{{ item.fileName }}</span>
|
||||||
|
<span class="file-details">
|
||||||
|
{{ (item.unitTime / 3600) | number:'1.1-1' }}h | {{ item.unitWeight | number:'1.0-0' }}g
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="item-controls">
|
||||||
|
<div class="qty-control">
|
||||||
|
<label>Qtà:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
[ngModel]="item.quantity"
|
||||||
|
(ngModelChange)="updateQuantity(i, $event)"
|
||||||
|
class="qty-input">
|
||||||
|
</div>
|
||||||
|
<div class="item-price">
|
||||||
|
{{ (item.unitPrice * item.quantity) | currency:result().currency }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<app-button variant="outline" (click)="consult.emit()">
|
||||||
|
{{ 'QUOTE.CONSULT' | translate }}
|
||||||
|
</app-button>
|
||||||
|
|
||||||
|
<app-button (click)="proceed.emit()">
|
||||||
|
{{ 'QUOTE.PROCEED_ORDER' | translate }}
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
</app-card>
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
.title { margin-bottom: var(--space-6); text-align: center; }
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--color-border);
|
||||||
|
margin: var(--space-4) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--color-neutral-50);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1; /* Ensure it takes available space */
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name { font-weight: 500; font-size: 0.9rem; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.file-details { font-size: 0.8rem; color: var(--color-text-muted); }
|
||||||
|
|
||||||
|
.item-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qty-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
|
||||||
|
label { font-size: 0.8rem; color: var(--color-text-muted); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.qty-input {
|
||||||
|
width: 60px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
text-align: center;
|
||||||
|
&:focus { outline: none; border-color: var(--color-brand); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-price {
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 60px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
|
||||||
|
@media(min-width: 500px) {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.full-width { grid-column: span 2; }
|
||||||
|
|
||||||
|
.setup-note {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||||
|
|
||||||
|
.notes-section {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--color-neutral-50);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
white-space: pre-wrap; /* Preserve line breaks */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,56 +1,74 @@
|
|||||||
import { Component, input, output } from '@angular/core';
|
import { Component, input, output, signal, computed, effect } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
import { AppCardComponent } from '../../../../shared/components/app-card/app-card.component';
|
import { AppCardComponent } from '../../../../shared/components/app-card/app-card.component';
|
||||||
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
|
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
|
||||||
import { SummaryCardComponent } from '../../../../shared/components/summary-card/summary-card.component';
|
import { SummaryCardComponent } from '../../../../shared/components/summary-card/summary-card.component';
|
||||||
import { QuoteResult } from '../../services/quote-estimator.service';
|
import { QuoteResult, QuoteItem } from '../../services/quote-estimator.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-quote-result',
|
selector: 'app-quote-result',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, TranslateModule, AppCardComponent, AppButtonComponent, SummaryCardComponent],
|
imports: [CommonModule, FormsModule, TranslateModule, AppCardComponent, AppButtonComponent, SummaryCardComponent],
|
||||||
template: `
|
templateUrl: './quote-result.component.html',
|
||||||
<app-card>
|
styleUrl: './quote-result.component.scss'
|
||||||
<h3 class="title">{{ 'CALC.RESULT' | translate }}</h3>
|
|
||||||
|
|
||||||
<div class="result-grid">
|
|
||||||
<app-summary-card
|
|
||||||
class="item full-width"
|
|
||||||
[label]="'CALC.COST' | translate"
|
|
||||||
[large]="true"
|
|
||||||
[highlight]="true">
|
|
||||||
{{ result().price | currency:result().currency }}
|
|
||||||
</app-summary-card>
|
|
||||||
|
|
||||||
<app-summary-card [label]="'CALC.TIME' | translate">
|
|
||||||
{{ result().printTimeHours }}h {{ result().printTimeMinutes }}m
|
|
||||||
</app-summary-card>
|
|
||||||
|
|
||||||
<app-summary-card [label]="'CALC.MATERIAL' | translate">
|
|
||||||
{{ result().materialUsageGrams }}g
|
|
||||||
</app-summary-card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
<app-button variant="primary" [fullWidth]="true">{{ 'CALC.ORDER' | translate }}</app-button>
|
|
||||||
<app-button variant="outline" [fullWidth]="true">{{ 'CALC.CONSULT' | translate }}</app-button>
|
|
||||||
</div>
|
|
||||||
</app-card>
|
|
||||||
`,
|
|
||||||
styles: [`
|
|
||||||
.title { margin-bottom: var(--space-6); text-align: center; }
|
|
||||||
.result-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: var(--space-4);
|
|
||||||
margin-bottom: var(--space-6);
|
|
||||||
}
|
|
||||||
.full-width { grid-column: span 2; }
|
|
||||||
|
|
||||||
.actions { display: flex; flex-direction: column; gap: var(--space-3); }
|
|
||||||
`]
|
|
||||||
})
|
})
|
||||||
export class QuoteResultComponent {
|
export class QuoteResultComponent {
|
||||||
result = input.required<QuoteResult>();
|
result = input.required<QuoteResult>();
|
||||||
|
consult = output<void>();
|
||||||
|
proceed = output<void>();
|
||||||
|
itemChange = output<{fileName: string, quantity: number}>();
|
||||||
|
|
||||||
|
// Local mutable state for items to handle quantity changes
|
||||||
|
items = signal<QuoteItem[]>([]);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
// Initialize local items when result inputs change
|
||||||
|
// We map to new objects to avoid mutating the input directly if it was a reference
|
||||||
|
this.items.set(this.result().items.map(i => ({...i})));
|
||||||
|
}, { allowSignalWrites: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateQuantity(index: number, newQty: number | string) {
|
||||||
|
const qty = typeof newQty === 'string' ? parseInt(newQty, 10) : newQty;
|
||||||
|
if (qty < 1 || isNaN(qty)) return;
|
||||||
|
|
||||||
|
this.items.update(current => {
|
||||||
|
const updated = [...current];
|
||||||
|
updated[index] = { ...updated[index], quantity: qty };
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.itemChange.emit({
|
||||||
|
fileName: this.items()[index].fileName,
|
||||||
|
quantity: qty
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
totals = computed(() => {
|
||||||
|
const currentItems = this.items();
|
||||||
|
const setup = this.result().setupCost;
|
||||||
|
|
||||||
|
let price = setup;
|
||||||
|
let time = 0;
|
||||||
|
let weight = 0;
|
||||||
|
|
||||||
|
currentItems.forEach(i => {
|
||||||
|
price += i.unitPrice * i.quantity;
|
||||||
|
time += i.unitTime * i.quantity;
|
||||||
|
weight += i.unitWeight * i.quantity;
|
||||||
|
});
|
||||||
|
|
||||||
|
const hours = Math.floor(time / 3600);
|
||||||
|
const minutes = Math.ceil((time % 3600) / 60);
|
||||||
|
|
||||||
|
return {
|
||||||
|
price: Math.round(price * 100) / 100,
|
||||||
|
hours,
|
||||||
|
minutes,
|
||||||
|
weight: Math.ceil(weight)
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
@if (selectedFile()) {
|
||||||
|
<div class="viewer-wrapper">
|
||||||
|
<app-stl-viewer
|
||||||
|
[file]="selectedFile()"
|
||||||
|
[color]="getSelectedFileColor()">
|
||||||
|
</app-stl-viewer>
|
||||||
|
<!-- Close button removed as requested -->
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Initial Dropzone (Visible only when no files) -->
|
||||||
|
@if (items().length === 0) {
|
||||||
|
<app-dropzone
|
||||||
|
[label]="'CALC.UPLOAD_LABEL' | translate"
|
||||||
|
[subtext]="'CALC.UPLOAD_SUB' | translate"
|
||||||
|
[accept]="acceptedFormats"
|
||||||
|
[multiple]="true"
|
||||||
|
(filesDropped)="onFilesDropped($event)">
|
||||||
|
</app-dropzone>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- New File List with Details -->
|
||||||
|
@if (items().length > 0) {
|
||||||
|
<div class="items-grid">
|
||||||
|
@for (item of items(); track item.file.name; let i = $index) {
|
||||||
|
<div class="file-card" [class.active]="item.file === selectedFile()" (click)="selectFile(item.file)">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="file-name" [title]="item.file.name">{{ item.file.name }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="card-controls">
|
||||||
|
<div class="qty-group">
|
||||||
|
<label>QTÀ</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
[value]="item.quantity"
|
||||||
|
(change)="updateItemQuantity(i, $event)"
|
||||||
|
class="qty-input"
|
||||||
|
(click)="$event.stopPropagation()">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="color-group">
|
||||||
|
<label>COLORE</label>
|
||||||
|
<app-color-selector
|
||||||
|
[selectedColor]="item.color"
|
||||||
|
[variants]="currentMaterialVariants()"
|
||||||
|
(colorSelected)="updateItemColor(i, $event)">
|
||||||
|
</app-color-selector>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" class="btn-remove" (click)="removeItem(i); $event.stopPropagation()" title="Remove file">
|
||||||
|
X
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- "Add Files" Button (Visible only when files exist) -->
|
||||||
|
<div class="add-more-container">
|
||||||
|
<input #additionalInput type="file" [accept]="acceptedFormats" multiple hidden (change)="onAdditionalFilesSelected($event)">
|
||||||
|
|
||||||
|
<button type="button" class="btn-add-more" (click)="additionalInput.click()">
|
||||||
|
+ {{ 'CALC.ADD_FILES' | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (items().length === 0 && form.get('itemsTouched')?.value) {
|
||||||
|
<div class="error-msg">{{ 'CALC.ERR_FILE_REQUIRED' | translate }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<app-select
|
||||||
|
formControlName="material"
|
||||||
|
[label]="'CALC.MATERIAL' | translate"
|
||||||
|
[options]="materials()"
|
||||||
|
></app-select>
|
||||||
|
|
||||||
|
@if (mode() === 'easy') {
|
||||||
|
<app-select
|
||||||
|
formControlName="quality"
|
||||||
|
[label]="'CALC.QUALITY' | translate"
|
||||||
|
[options]="qualities()"
|
||||||
|
></app-select>
|
||||||
|
} @else {
|
||||||
|
<app-select
|
||||||
|
formControlName="nozzleDiameter"
|
||||||
|
[label]="'CALC.NOZZLE' | translate"
|
||||||
|
[options]="nozzleDiameters()"
|
||||||
|
></app-select>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Global quantity removed, now per item -->
|
||||||
|
|
||||||
|
@if (mode() === 'advanced') {
|
||||||
|
<div class="grid">
|
||||||
|
<app-select
|
||||||
|
formControlName="infillPattern"
|
||||||
|
[label]="'CALC.PATTERN' | translate"
|
||||||
|
[options]="infillPatterns()"
|
||||||
|
></app-select>
|
||||||
|
|
||||||
|
<app-select
|
||||||
|
formControlName="layerHeight"
|
||||||
|
[label]="'CALC.LAYER_HEIGHT' | translate"
|
||||||
|
[options]="layerHeights()"
|
||||||
|
></app-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<app-input
|
||||||
|
formControlName="infillDensity"
|
||||||
|
type="number"
|
||||||
|
[label]="'CALC.INFILL' | translate"
|
||||||
|
></app-input>
|
||||||
|
|
||||||
|
<div class="checkbox-row">
|
||||||
|
<input type="checkbox" formControlName="supportEnabled" id="support">
|
||||||
|
<label for="support">{{ 'CALC.SUPPORT' | translate }}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
<app-input
|
||||||
|
formControlName="notes"
|
||||||
|
[label]="'CALC.NOTES' | translate"
|
||||||
|
placeholder="Istruzioni specifiche..."
|
||||||
|
></app-input>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<!-- Progress Bar (Only when uploading i.e. progress < 100) -->
|
||||||
|
@if (loading() && uploadProgress() < 100) {
|
||||||
|
<div class="progress-container">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" [style.width.%]="uploadProgress()"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<app-button
|
||||||
|
type="submit"
|
||||||
|
[disabled]="items().length === 0 || loading()"
|
||||||
|
[fullWidth]="true">
|
||||||
|
{{ loading() ? (uploadProgress() < 100 ? 'Uploading...' : 'Processing...') : ('CALC.CALCULATE' | translate) }}
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
.section { margin-bottom: var(--space-6); }
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-4);
|
||||||
|
|
||||||
|
@media(min-width: 640px) {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.actions { margin-top: var(--space-6); }
|
||||||
|
.error-msg { color: var(--color-danger-500); font-size: 0.875rem; margin-top: var(--space-2); text-align: center; }
|
||||||
|
|
||||||
|
.viewer-wrapper { position: relative; margin-bottom: var(--space-4); }
|
||||||
|
|
||||||
|
/* Grid Layout for Files */
|
||||||
|
.items-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr; /* Force 2 columns on mobile */
|
||||||
|
gap: var(--space-2); /* Tighten gap for mobile */
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
|
||||||
|
@media(min-width: 640px) {
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-card {
|
||||||
|
padding: var(--space-2); /* Reduced from space-3 */
|
||||||
|
background: var(--color-neutral-100);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: all 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px; /* Reduced gap */
|
||||||
|
position: relative; /* For absolute positioning of remove btn */
|
||||||
|
min-width: 0; /* Allow flex item to shrink below content size if needed */
|
||||||
|
|
||||||
|
&:hover { border-color: var(--color-neutral-300); }
|
||||||
|
&.active {
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
background: rgba(250, 207, 10, 0.05);
|
||||||
|
box-shadow: 0 0 0 1px var(--color-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
overflow: hidden;
|
||||||
|
padding-right: 25px; /* Adjusted */
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.8rem; /* Smaller font */
|
||||||
|
color: var(--color-text);
|
||||||
|
display: block;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end; /* Align bottom of input and color circle */
|
||||||
|
gap: 16px; /* Space between Qty and Color */
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qty-group, .color-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column; /* Stack label and input */
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-group {
|
||||||
|
align-items: flex-start; /* Align label left */
|
||||||
|
/* margin-right removed */
|
||||||
|
|
||||||
|
/* Override margin in selector for this context */
|
||||||
|
::ng-deep .color-selector-container {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.qty-input {
|
||||||
|
width: 36px; /* Slightly smaller */
|
||||||
|
padding: 1px 2px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
background: white;
|
||||||
|
height: 24px; /* Explicit height to match color circle somewhat */
|
||||||
|
&:focus { outline: none; border-color: var(--color-brand); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-danger-100);
|
||||||
|
color: var(--color-danger-500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prominent Add Button */
|
||||||
|
.add-more-container {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add-more {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--color-neutral-800);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-neutral-900);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
&:active { transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
height: 100%;
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
accent-color: var(--color-brand);
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress Bar */
|
||||||
|
.progress-container {
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.progress-bar {
|
||||||
|
height: 4px;
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 0;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--color-brand);
|
||||||
|
width: 0%;
|
||||||
|
transition: width 0.2s ease-out;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, input, output, signal } from '@angular/core';
|
import { Component, input, output, signal, effect, OnInit, inject } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
@@ -7,281 +7,140 @@ import { AppSelectComponent } from '../../../../shared/components/app-select/app
|
|||||||
import { AppDropzoneComponent } from '../../../../shared/components/app-dropzone/app-dropzone.component';
|
import { AppDropzoneComponent } from '../../../../shared/components/app-dropzone/app-dropzone.component';
|
||||||
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
|
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
|
||||||
import { StlViewerComponent } from '../../../../shared/components/stl-viewer/stl-viewer.component';
|
import { StlViewerComponent } from '../../../../shared/components/stl-viewer/stl-viewer.component';
|
||||||
import { QuoteRequest } from '../../services/quote-estimator.service';
|
import { ColorSelectorComponent } from '../../../../shared/components/color-selector/color-selector.component';
|
||||||
|
import { QuoteRequest, QuoteEstimatorService, OptionsResponse, SimpleOption, MaterialOption, VariantOption } from '../../services/quote-estimator.service';
|
||||||
|
import { getColorHex } from '../../../../core/constants/colors.const';
|
||||||
|
|
||||||
|
interface FormItem {
|
||||||
|
file: File;
|
||||||
|
quantity: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-upload-form',
|
selector: 'app-upload-form',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppSelectComponent, AppDropzoneComponent, AppButtonComponent, StlViewerComponent],
|
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppSelectComponent, AppDropzoneComponent, AppButtonComponent, StlViewerComponent, ColorSelectorComponent],
|
||||||
template: `
|
templateUrl: './upload-form.component.html',
|
||||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
styleUrl: './upload-form.component.scss'
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
@if (selectedFile()) {
|
|
||||||
<div class="viewer-wrapper">
|
|
||||||
<app-stl-viewer [file]="selectedFile()"></app-stl-viewer>
|
|
||||||
<button type="button" class="btn-clear" (click)="clearFiles()">
|
|
||||||
X
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="file-list">
|
|
||||||
@for (f of files(); track f.name) {
|
|
||||||
<div class="file-item" [class.active]="f === selectedFile()" (click)="selectFile(f)">
|
|
||||||
{{ f.name }}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<app-dropzone
|
|
||||||
[label]="'CALC.UPLOAD_LABEL' | translate"
|
|
||||||
[subtext]="'CALC.UPLOAD_SUB' | translate"
|
|
||||||
[accept]="acceptedFormats"
|
|
||||||
[multiple]="true"
|
|
||||||
(filesDropped)="onFilesDropped($event)">
|
|
||||||
</app-dropzone>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (form.get('files')?.invalid && form.get('files')?.touched) {
|
|
||||||
<div class="error-msg">{{ 'CALC.ERR_FILE_REQUIRED' | translate }}</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid">
|
|
||||||
<app-select
|
|
||||||
formControlName="material"
|
|
||||||
[label]="'CALC.MATERIAL' | translate"
|
|
||||||
[options]="materials"
|
|
||||||
></app-select>
|
|
||||||
|
|
||||||
<app-select
|
|
||||||
formControlName="quality"
|
|
||||||
[label]="'CALC.QUALITY' | translate"
|
|
||||||
[options]="qualities"
|
|
||||||
></app-select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<app-input
|
|
||||||
formControlName="quantity"
|
|
||||||
type="number"
|
|
||||||
[label]="'CALC.QUANTITY' | translate"
|
|
||||||
></app-input>
|
|
||||||
|
|
||||||
@if (mode() === 'advanced') {
|
|
||||||
<div class="grid">
|
|
||||||
<app-select
|
|
||||||
formControlName="color"
|
|
||||||
[label]="'CALC.COLOR' | translate"
|
|
||||||
[options]="colors"
|
|
||||||
></app-select>
|
|
||||||
|
|
||||||
<app-select
|
|
||||||
formControlName="infillPattern"
|
|
||||||
[label]="'CALC.PATTERN' | translate"
|
|
||||||
[options]="infillPatterns"
|
|
||||||
></app-select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid">
|
|
||||||
<app-input
|
|
||||||
formControlName="infillDensity"
|
|
||||||
type="number"
|
|
||||||
[label]="'CALC.INFILL' | translate"
|
|
||||||
></app-input>
|
|
||||||
|
|
||||||
<div class="checkbox-row">
|
|
||||||
<input type="checkbox" formControlName="supportEnabled" id="support">
|
|
||||||
<label for="support">{{ 'CALC.SUPPORT' | translate }}</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<app-input
|
|
||||||
formControlName="notes"
|
|
||||||
[label]="'CALC.NOTES' | translate"
|
|
||||||
placeholder="Istruzioni specifiche..."
|
|
||||||
></app-input>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (loading()) {
|
|
||||||
<div class="progress-container">
|
|
||||||
<div class="progress-bar">
|
|
||||||
<div class="progress-fill"></div>
|
|
||||||
</div>
|
|
||||||
<p class="progress-text">Uploading & Analyzing...</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
<app-button
|
|
||||||
type="submit"
|
|
||||||
[disabled]="form.invalid || loading()"
|
|
||||||
[fullWidth]="true">
|
|
||||||
@if (loading()) {
|
|
||||||
Slicing in progress...
|
|
||||||
} @else {
|
|
||||||
{{ 'CALC.CALCULATE' | translate }}
|
|
||||||
}
|
|
||||||
</app-button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
`,
|
|
||||||
styles: [`
|
|
||||||
.section { margin-bottom: var(--space-6); }
|
|
||||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); }
|
|
||||||
.actions { margin-top: var(--space-6); }
|
|
||||||
.error-msg { color: var(--color-danger-500); font-size: 0.875rem; margin-top: var(--space-2); text-align: center; }
|
|
||||||
|
|
||||||
.viewer-wrapper { position: relative; margin-bottom: var(--space-4); }
|
|
||||||
.btn-clear {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
right: 10px;
|
|
||||||
background: rgba(0,0,0,0.5);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 50%;
|
|
||||||
cursor: pointer;
|
|
||||||
z-index: 10;
|
|
||||||
&:hover { background: rgba(0,0,0,0.7); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-list {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-2);
|
|
||||||
overflow-x: auto;
|
|
||||||
padding-bottom: var(--space-2);
|
|
||||||
}
|
|
||||||
.file-item {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
background: var(--color-neutral-100);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
&:hover { background: var(--color-neutral-200); }
|
|
||||||
&.active {
|
|
||||||
border-color: var(--color-brand);
|
|
||||||
background: rgba(250, 207, 10, 0.1);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
height: 100%;
|
|
||||||
padding-top: var(--space-4);
|
|
||||||
|
|
||||||
input[type="checkbox"] {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
accent-color: var(--color-brand);
|
|
||||||
}
|
|
||||||
label {
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Progress Bar */
|
|
||||||
.progress-container {
|
|
||||||
margin-top: var(--space-4);
|
|
||||||
padding: var(--space-4);
|
|
||||||
background: var(--color-neutral-100);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.progress-bar {
|
|
||||||
height: 6px;
|
|
||||||
background: var(--color-border);
|
|
||||||
border-radius: 3px;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.progress-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: var(--color-brand);
|
|
||||||
width: 0%;
|
|
||||||
animation: progress 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
.progress-text { font-size: 0.875rem; color: var(--color-text-muted); }
|
|
||||||
|
|
||||||
@keyframes progress {
|
|
||||||
0% { width: 0%; transform: translateX(-100%); }
|
|
||||||
50% { width: 100%; transform: translateX(0); }
|
|
||||||
100% { width: 100%; transform: translateX(100%); }
|
|
||||||
}
|
|
||||||
`]
|
|
||||||
})
|
})
|
||||||
export class UploadFormComponent {
|
export class UploadFormComponent implements OnInit {
|
||||||
mode = input<'easy' | 'advanced'>('easy');
|
mode = input<'easy' | 'advanced'>('easy');
|
||||||
loading = input<boolean>(false);
|
loading = input<boolean>(false);
|
||||||
|
uploadProgress = input<number>(0);
|
||||||
submitRequest = output<QuoteRequest>();
|
submitRequest = output<QuoteRequest>();
|
||||||
|
|
||||||
|
private estimator = inject(QuoteEstimatorService);
|
||||||
|
private fb = inject(FormBuilder);
|
||||||
|
|
||||||
form: FormGroup;
|
form: FormGroup;
|
||||||
|
|
||||||
files = signal<File[]>([]);
|
items = signal<FormItem[]>([]);
|
||||||
selectedFile = signal<File | null>(null);
|
selectedFile = signal<File | null>(null);
|
||||||
|
|
||||||
materials = [
|
// Dynamic Options
|
||||||
{ label: 'PLA (Standard)', value: 'PLA' },
|
materials = signal<SimpleOption[]>([]);
|
||||||
{ label: 'PETG (Resistente)', value: 'PETG' },
|
qualities = signal<SimpleOption[]>([]);
|
||||||
{ label: 'TPU (Flessibile)', value: 'TPU' }
|
nozzleDiameters = signal<SimpleOption[]>([]);
|
||||||
];
|
infillPatterns = signal<SimpleOption[]>([]);
|
||||||
|
layerHeights = signal<SimpleOption[]>([]);
|
||||||
|
|
||||||
qualities = [
|
// Store full material options to lookup variants/colors if needed later
|
||||||
{ label: 'Bozza (Fast)', value: 'Draft' },
|
private fullMaterialOptions: MaterialOption[] = [];
|
||||||
{ label: 'Standard', value: 'Standard' },
|
|
||||||
{ label: 'Alta definizione', value: 'High' }
|
|
||||||
];
|
|
||||||
|
|
||||||
colors = [
|
// Computed variants for valid material
|
||||||
{ label: 'Black', value: 'Black' },
|
currentMaterialVariants = signal<VariantOption[]>([]);
|
||||||
{ label: 'White', value: 'White' },
|
|
||||||
{ label: 'Gray', value: 'Gray' },
|
private updateVariants() {
|
||||||
{ label: 'Red', value: 'Red' },
|
const matCode = this.form.get('material')?.value;
|
||||||
{ label: 'Blue', value: 'Blue' },
|
if (matCode && this.fullMaterialOptions.length > 0) {
|
||||||
{ label: 'Green', value: 'Green' },
|
const found = this.fullMaterialOptions.find(m => m.code === matCode);
|
||||||
{ label: 'Yellow', value: 'Yellow' }
|
this.currentMaterialVariants.set(found ? found.variants : []);
|
||||||
];
|
} else {
|
||||||
infillPatterns = [
|
this.currentMaterialVariants.set([]);
|
||||||
{ label: 'Grid', value: 'grid' },
|
}
|
||||||
{ label: 'Gyroid', value: 'gyroid' },
|
}
|
||||||
{ label: 'Cubic', value: 'cubic' },
|
|
||||||
{ label: 'Triangles', value: 'triangles' }
|
|
||||||
];
|
|
||||||
|
|
||||||
acceptedFormats = '.stl,.3mf,.step,.stp,.obj,.amf,.ply,.igs,.iges';
|
acceptedFormats = '.stl,.3mf,.step,.stp,.obj,.amf,.ply,.igs,.iges';
|
||||||
|
|
||||||
constructor(private fb: FormBuilder) {
|
constructor() {
|
||||||
this.form = this.fb.group({
|
this.form = this.fb.group({
|
||||||
files: [[], Validators.required],
|
itemsTouched: [false], // Hack to track touched state for custom items list
|
||||||
material: ['PLA', Validators.required],
|
material: ['', Validators.required],
|
||||||
quality: ['Standard', Validators.required],
|
quality: ['', Validators.required],
|
||||||
quantity: [1, [Validators.required, Validators.min(1)]],
|
items: [[]], // Track items in form for validation if needed
|
||||||
notes: [''],
|
notes: [''],
|
||||||
// Advanced fields
|
// Advanced fields
|
||||||
color: ['Black'],
|
|
||||||
infillDensity: [20, [Validators.min(0), Validators.max(100)]],
|
infillDensity: [20, [Validators.min(0), Validators.max(100)]],
|
||||||
|
layerHeight: [0.2, [Validators.min(0.05), Validators.max(1.0)]],
|
||||||
|
nozzleDiameter: [0.4, Validators.required],
|
||||||
infillPattern: ['grid'],
|
infillPattern: ['grid'],
|
||||||
supportEnabled: [false]
|
supportEnabled: [false]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Listen to material changes to update variants
|
||||||
|
this.form.get('material')?.valueChanges.subscribe(() => {
|
||||||
|
this.updateVariants();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.estimator.getOptions().subscribe({
|
||||||
|
next: (options: OptionsResponse) => {
|
||||||
|
this.fullMaterialOptions = options.materials;
|
||||||
|
this.updateVariants(); // Trigger initial update
|
||||||
|
|
||||||
|
this.materials.set(options.materials.map(m => ({ label: m.label, value: m.code })));
|
||||||
|
this.qualities.set(options.qualities.map(q => ({ label: q.label, value: q.id })));
|
||||||
|
this.infillPatterns.set(options.infillPatterns.map(p => ({ label: p.label, value: p.id })));
|
||||||
|
this.layerHeights.set(options.layerHeights.map(l => ({ label: l.label, value: l.value })));
|
||||||
|
this.nozzleDiameters.set(options.nozzleDiameters.map(n => ({ label: n.label, value: n.value })));
|
||||||
|
|
||||||
|
this.setDefaults();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Failed to load options', err);
|
||||||
|
// Fallback for debugging/offline dev
|
||||||
|
this.materials.set([{ label: 'PLA (Fallback)', value: 'PLA' }]);
|
||||||
|
this.qualities.set([{ label: 'Standard', value: 'standard' }]);
|
||||||
|
this.nozzleDiameters.set([{ label: '0.4 mm', value: 0.4 }]);
|
||||||
|
this.setDefaults();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private setDefaults() {
|
||||||
|
// Set Defaults if available
|
||||||
|
if (this.materials().length > 0 && !this.form.get('material')?.value) {
|
||||||
|
this.form.get('material')?.setValue(this.materials()[0].value);
|
||||||
|
}
|
||||||
|
if (this.qualities().length > 0 && !this.form.get('quality')?.value) {
|
||||||
|
// Try to find 'standard' or use first
|
||||||
|
const std = this.qualities().find(q => q.value === 'standard');
|
||||||
|
this.form.get('quality')?.setValue(std ? std.value : this.qualities()[0].value);
|
||||||
|
}
|
||||||
|
if (this.nozzleDiameters().length > 0 && !this.form.get('nozzleDiameter')?.value) {
|
||||||
|
this.form.get('nozzleDiameter')?.setValue(0.4); // Prefer 0.4
|
||||||
|
}
|
||||||
|
if (this.layerHeights().length > 0 && !this.form.get('layerHeight')?.value) {
|
||||||
|
this.form.get('layerHeight')?.setValue(0.2); // Prefer 0.2
|
||||||
|
}
|
||||||
|
if (this.infillPatterns().length > 0 && !this.form.get('infillPattern')?.value) {
|
||||||
|
this.form.get('infillPattern')?.setValue(this.infillPatterns()[0].value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onFilesDropped(newFiles: File[]) {
|
onFilesDropped(newFiles: File[]) {
|
||||||
const MAX_SIZE = 200 * 1024 * 1024; // 200MB
|
const MAX_SIZE = 200 * 1024 * 1024; // 200MB
|
||||||
const validFiles: File[] = [];
|
const validItems: FormItem[] = [];
|
||||||
let hasError = false;
|
let hasError = false;
|
||||||
|
|
||||||
for (const file of newFiles) {
|
for (const file of newFiles) {
|
||||||
if (file.size > MAX_SIZE) {
|
if (file.size > MAX_SIZE) {
|
||||||
hasError = true;
|
hasError = true;
|
||||||
} else {
|
} else {
|
||||||
validFiles.push(file);
|
// Default color is Black
|
||||||
|
validItems.push({ file, quantity: 1, color: 'Black' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,32 +148,112 @@ export class UploadFormComponent {
|
|||||||
alert("Alcuni file superano il limite di 200MB e non sono stati aggiunti.");
|
alert("Alcuni file superano il limite di 200MB e non sono stati aggiunti.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (validFiles.length > 0) {
|
if (validItems.length > 0) {
|
||||||
this.files.update(current => [...current, ...validFiles]);
|
this.items.update(current => [...current, ...validItems]);
|
||||||
this.form.patchValue({ files: this.files() });
|
this.form.get('itemsTouched')?.setValue(true);
|
||||||
this.form.get('files')?.markAsTouched();
|
// Auto select last added
|
||||||
this.selectedFile.set(validFiles[validFiles.length - 1]);
|
this.selectedFile.set(validItems[validItems.length - 1].file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onAdditionalFilesSelected(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
if (input.files && input.files.length > 0) {
|
||||||
|
this.onFilesDropped(Array.from(input.files));
|
||||||
|
// Reset input so same files can be selected again if needed
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateItemQuantityByName(fileName: string, quantity: number) {
|
||||||
|
this.items.update(current => {
|
||||||
|
return current.map(item => {
|
||||||
|
if (item.file.name === fileName) {
|
||||||
|
return { ...item, quantity };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
selectFile(file: File) {
|
selectFile(file: File) {
|
||||||
this.selectedFile.set(file);
|
if (this.selectedFile() === file) {
|
||||||
|
// toggle off? no, keep active
|
||||||
|
} else {
|
||||||
|
this.selectedFile.set(file);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clearFiles() {
|
// Helper to get color of currently selected file
|
||||||
this.files.set([]);
|
getSelectedFileColor(): string {
|
||||||
this.selectedFile.set(null);
|
const file = this.selectedFile();
|
||||||
this.form.patchValue({ files: [] });
|
if (!file) return '#facf0a'; // Default
|
||||||
|
|
||||||
|
const item = this.items().find(i => i.file === file);
|
||||||
|
if (item) {
|
||||||
|
const vars = this.currentMaterialVariants();
|
||||||
|
if (vars && vars.length > 0) {
|
||||||
|
const found = vars.find(v => v.colorName === item.color);
|
||||||
|
if (found) return found.hexColor;
|
||||||
|
}
|
||||||
|
return getColorHex(item.color);
|
||||||
|
}
|
||||||
|
return '#facf0a';
|
||||||
|
}
|
||||||
|
|
||||||
|
updateItemQuantity(index: number, event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
let val = parseInt(input.value, 10);
|
||||||
|
if (isNaN(val) || val < 1) val = 1;
|
||||||
|
|
||||||
|
this.items.update(current => {
|
||||||
|
const updated = [...current];
|
||||||
|
updated[index] = { ...updated[index], quantity: val };
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateItemColor(index: number, newColor: string) {
|
||||||
|
this.items.update(current => {
|
||||||
|
const updated = [...current];
|
||||||
|
updated[index] = { ...updated[index], color: newColor };
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
removeItem(index: number) {
|
||||||
|
this.items.update(current => {
|
||||||
|
const updated = [...current];
|
||||||
|
const removed = updated.splice(index, 1)[0];
|
||||||
|
if (this.selectedFile() === removed.file) {
|
||||||
|
this.selectedFile.set(null);
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onSubmit() {
|
onSubmit() {
|
||||||
if (this.form.valid) {
|
console.log('UploadFormComponent: onSubmit triggered');
|
||||||
|
console.log('Form Valid:', this.form.valid, 'Items:', this.items().length);
|
||||||
|
|
||||||
|
if (this.form.valid && this.items().length > 0) {
|
||||||
|
console.log('UploadFormComponent: Emitting submitRequest', this.form.value);
|
||||||
this.submitRequest.emit({
|
this.submitRequest.emit({
|
||||||
...this.form.value,
|
...this.form.value,
|
||||||
|
items: this.items(), // Pass the items array explicitly AFTER form value to prevent overwrite
|
||||||
mode: this.mode()
|
mode: this.mode()
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
console.warn('UploadFormComponent: Form Invalid or No Items');
|
||||||
|
console.log('Form Errors:', this.form.errors);
|
||||||
|
Object.keys(this.form.controls).forEach(key => {
|
||||||
|
const control = this.form.get(key);
|
||||||
|
if (control?.invalid) {
|
||||||
|
console.log('Invalid Control:', key, control.errors, 'Value:', control.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
this.form.markAllAsTouched();
|
this.form.markAllAsTouched();
|
||||||
|
this.form.get('itemsTouched')?.setValue(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<div class="user-details-container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<app-card [title]="'USER_DETAILS.TITLE' | translate">
|
||||||
|
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||||
|
|
||||||
|
<!-- Name & Surname -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<app-input
|
||||||
|
formControlName="name"
|
||||||
|
label="USER_DETAILS.NAME"
|
||||||
|
placeholder="USER_DETAILS.NAME_PLACEHOLDER"
|
||||||
|
[required]="true"
|
||||||
|
[error]="form.get('name')?.invalid && form.get('name')?.touched ? ('COMMON.REQUIRED' | translate) : null">
|
||||||
|
</app-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<app-input
|
||||||
|
formControlName="surname"
|
||||||
|
label="USER_DETAILS.SURNAME"
|
||||||
|
placeholder="USER_DETAILS.SURNAME_PLACEHOLDER"
|
||||||
|
[required]="true"
|
||||||
|
[error]="form.get('surname')?.invalid && form.get('surname')?.touched ? ('COMMON.REQUIRED' | translate) : null">
|
||||||
|
</app-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email & Phone -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<app-input
|
||||||
|
formControlName="email"
|
||||||
|
label="USER_DETAILS.EMAIL"
|
||||||
|
type="email"
|
||||||
|
placeholder="USER_DETAILS.EMAIL_PLACEHOLDER"
|
||||||
|
[required]="true"
|
||||||
|
[error]="form.get('email')?.invalid && form.get('email')?.touched ? ('COMMON.INVALID_EMAIL' | translate) : null">
|
||||||
|
</app-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<app-input
|
||||||
|
formControlName="phone"
|
||||||
|
label="USER_DETAILS.PHONE"
|
||||||
|
type="tel"
|
||||||
|
placeholder="USER_DETAILS.PHONE_PLACEHOLDER"
|
||||||
|
[required]="true"
|
||||||
|
[error]="form.get('phone')?.invalid && form.get('phone')?.touched ? ('COMMON.REQUIRED' | translate) : null">
|
||||||
|
</app-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Address -->
|
||||||
|
<app-input
|
||||||
|
formControlName="address"
|
||||||
|
label="USER_DETAILS.ADDRESS"
|
||||||
|
placeholder="USER_DETAILS.ADDRESS_PLACEHOLDER"
|
||||||
|
[required]="true"
|
||||||
|
[error]="form.get('address')?.invalid && form.get('address')?.touched ? ('COMMON.REQUIRED' | translate) : null">
|
||||||
|
</app-input>
|
||||||
|
|
||||||
|
<!-- Zip & City -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<app-input
|
||||||
|
formControlName="zip"
|
||||||
|
label="USER_DETAILS.ZIP"
|
||||||
|
placeholder="USER_DETAILS.ZIP_PLACEHOLDER"
|
||||||
|
[required]="true"
|
||||||
|
[error]="form.get('zip')?.invalid && form.get('zip')?.touched ? ('COMMON.REQUIRED' | translate) : null">
|
||||||
|
</app-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<app-input
|
||||||
|
formControlName="city"
|
||||||
|
label="USER_DETAILS.CITY"
|
||||||
|
placeholder="USER_DETAILS.CITY_PLACEHOLDER"
|
||||||
|
[required]="true"
|
||||||
|
[error]="form.get('city')?.invalid && form.get('city')?.touched ? ('COMMON.REQUIRED' | translate) : null">
|
||||||
|
</app-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<app-button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
(click)="onCancel()">
|
||||||
|
{{ 'COMMON.BACK' | translate }}
|
||||||
|
</app-button>
|
||||||
|
<app-button
|
||||||
|
type="submit"
|
||||||
|
[disabled]="form.invalid || submitting()">
|
||||||
|
{{ 'USER_DETAILS.SUBMIT' | translate }}
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</app-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Order Summary Column -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<app-card [title]="'USER_DETAILS.SUMMARY_TITLE' | translate">
|
||||||
|
|
||||||
|
<div class="summary-content" *ngIf="quote()">
|
||||||
|
<div class="summary-item" *ngFor="let item of quote()!.items">
|
||||||
|
<div class="item-info">
|
||||||
|
<span class="item-name">{{ item.fileName }}</span>
|
||||||
|
<span class="item-meta">{{ item.material }} - {{ item.color || 'Default' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="item-qty">x{{ item.quantity }}</div>
|
||||||
|
<div class="item-price">{{ (item.unitPrice * item.quantity) | currency:'CHF' }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="total-row">
|
||||||
|
<span>{{ 'QUOTE.TOTAL' | translate }}</span>
|
||||||
|
<span class="total-price">{{ quote()!.totalPrice | currency:'CHF' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</app-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
.user-details-container {
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 0 -0.5rem;
|
||||||
|
|
||||||
|
> [class*='col-'] {
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-md-6 {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-md-4 {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
width: 33.333%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-md-8 {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
width: 66.666%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary Styles
|
||||||
|
.summary-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-meta {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-qty {
|
||||||
|
margin: 0 1rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-price {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 2px solid rgba(255, 255, 255, 0.2);
|
||||||
|
|
||||||
|
.total-price {
|
||||||
|
color: var(--primary-color, #00C853); // Fallback color
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { Component, input, output, signal } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { AppCardComponent } from '../../../../shared/components/app-card/app-card.component';
|
||||||
|
import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component';
|
||||||
|
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
|
||||||
|
import { QuoteResult } from '../../services/quote-estimator.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-user-details',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppCardComponent, AppInputComponent, AppButtonComponent],
|
||||||
|
templateUrl: './user-details.component.html',
|
||||||
|
styleUrl: './user-details.component.scss'
|
||||||
|
})
|
||||||
|
export class UserDetailsComponent {
|
||||||
|
quote = input<QuoteResult>();
|
||||||
|
submitOrder = output<any>();
|
||||||
|
cancel = output<void>();
|
||||||
|
|
||||||
|
form: FormGroup;
|
||||||
|
submitting = signal(false);
|
||||||
|
|
||||||
|
constructor(private fb: FormBuilder) {
|
||||||
|
this.form = this.fb.group({
|
||||||
|
name: ['', Validators.required],
|
||||||
|
surname: ['', Validators.required],
|
||||||
|
email: ['', [Validators.required, Validators.email]],
|
||||||
|
phone: ['', Validators.required],
|
||||||
|
address: ['', Validators.required],
|
||||||
|
zip: ['', Validators.required],
|
||||||
|
city: ['', Validators.required]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit() {
|
||||||
|
if (this.form.valid) {
|
||||||
|
this.submitting.set(true);
|
||||||
|
|
||||||
|
const orderData = {
|
||||||
|
customer: this.form.value,
|
||||||
|
quote: this.quote()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simulate API delay
|
||||||
|
setTimeout(() => {
|
||||||
|
this.submitOrder.emit(orderData);
|
||||||
|
this.submitting.set(false);
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
this.form.markAllAsTouched();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onCancel() {
|
||||||
|
this.cancel.emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,29 +1,41 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient, HttpEventType } from '@angular/common/http';
|
||||||
import { Observable, forkJoin, of } from 'rxjs';
|
import { Observable, of } from 'rxjs';
|
||||||
import { map, catchError } from 'rxjs/operators';
|
import { map, catchError, tap } from 'rxjs/operators';
|
||||||
import { environment } from '../../../../environments/environment';
|
import { environment } from '../../../../environments/environment';
|
||||||
|
|
||||||
export interface QuoteRequest {
|
export interface QuoteRequest {
|
||||||
files: File[];
|
items: { file: File, quantity: number, color?: string }[];
|
||||||
material: string;
|
material: string;
|
||||||
quality: string;
|
quality: string;
|
||||||
quantity: number;
|
|
||||||
notes?: string;
|
notes?: string;
|
||||||
color?: string;
|
|
||||||
infillDensity?: number;
|
infillDensity?: number;
|
||||||
infillPattern?: string;
|
infillPattern?: string;
|
||||||
supportEnabled?: boolean;
|
supportEnabled?: boolean;
|
||||||
|
layerHeight?: number;
|
||||||
|
nozzleDiameter?: number;
|
||||||
mode: 'easy' | 'advanced';
|
mode: 'easy' | 'advanced';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface QuoteItem {
|
||||||
|
fileName: string;
|
||||||
|
unitPrice: number;
|
||||||
|
unitTime: number; // seconds
|
||||||
|
unitWeight: number; // grams
|
||||||
|
quantity: number;
|
||||||
|
material?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface QuoteResult {
|
export interface QuoteResult {
|
||||||
price: number;
|
items: QuoteItem[];
|
||||||
currency: string;
|
|
||||||
printTimeHours: number;
|
|
||||||
printTimeMinutes: number;
|
|
||||||
materialUsageGrams: number;
|
|
||||||
setupCost: number;
|
setupCost: number;
|
||||||
|
currency: string;
|
||||||
|
totalPrice: number;
|
||||||
|
totalTimeHours: number;
|
||||||
|
totalTimeMinutes: number;
|
||||||
|
totalWeight: number;
|
||||||
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BackendResponse {
|
interface BackendResponse {
|
||||||
@@ -38,86 +50,279 @@ interface BackendResponse {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BackendQuoteResult {
|
||||||
|
totalPrice: number;
|
||||||
|
currency: string;
|
||||||
|
setupCost: number;
|
||||||
|
stats: {
|
||||||
|
printTimeSeconds: number;
|
||||||
|
printTimeFormatted: string;
|
||||||
|
filamentWeightGrams: number;
|
||||||
|
filamentLengthMm: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options Interfaces
|
||||||
|
export interface MaterialOption {
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
variants: VariantOption[];
|
||||||
|
}
|
||||||
|
export interface VariantOption {
|
||||||
|
name: string;
|
||||||
|
colorName: string;
|
||||||
|
hexColor: string;
|
||||||
|
isOutOfStock: boolean;
|
||||||
|
}
|
||||||
|
export interface QualityOption {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
export interface InfillOption {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
export interface NumericOption {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OptionsResponse {
|
||||||
|
materials: MaterialOption[];
|
||||||
|
qualities: QualityOption[];
|
||||||
|
infillPatterns: InfillOption[];
|
||||||
|
layerHeights: NumericOption[];
|
||||||
|
nozzleDiameters: NumericOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI Option for Select Component
|
||||||
|
export interface SimpleOption {
|
||||||
|
value: string | number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class QuoteEstimatorService {
|
export class QuoteEstimatorService {
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
calculate(request: QuoteRequest): Observable<QuoteResult> {
|
getOptions(): Observable<OptionsResponse> {
|
||||||
const requests: Observable<BackendResponse>[] = request.files.map(file => {
|
console.log('QuoteEstimatorService: Requesting options...');
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
formData.append('machine', 'bambu_a1'); // Hardcoded for now
|
|
||||||
formData.append('filament', this.mapMaterial(request.material));
|
|
||||||
formData.append('quality', this.mapQuality(request.quality));
|
|
||||||
|
|
||||||
if (request.mode === 'advanced') {
|
|
||||||
if (request.color) formData.append('material_color', request.color);
|
|
||||||
if (request.infillDensity) formData.append('infill_density', request.infillDensity.toString());
|
|
||||||
if (request.infillPattern) formData.append('infill_pattern', request.infillPattern);
|
|
||||||
if (request.supportEnabled) formData.append('support_enabled', 'true');
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers: any = {};
|
const headers: any = {};
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (environment.basicAuth) {
|
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
|
||||||
// @ts-ignore
|
return this.http.get<OptionsResponse>(`${environment.apiUrl}/api/calculator/options`, { headers }).pipe(
|
||||||
headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
|
tap({
|
||||||
}
|
next: (res) => console.log('QuoteEstimatorService: Options loaded', res),
|
||||||
|
error: (err) => console.error('QuoteEstimatorService: Options failed', err)
|
||||||
console.log(`Sending file: ${file.name} to ${environment.apiUrl}/api/quote`);
|
})
|
||||||
return this.http.post<BackendResponse>(`${environment.apiUrl}/api/quote`, formData, { headers }).pipe(
|
|
||||||
map(res => {
|
|
||||||
console.log('Response for', file.name, res);
|
|
||||||
return res;
|
|
||||||
}),
|
|
||||||
catchError(err => {
|
|
||||||
console.error('Error calculating quote for', file.name, err);
|
|
||||||
return of({ success: false, data: { print_time_seconds: 0, material_grams: 0, cost: { total: 0 } }, error: err.message });
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
return forkJoin(requests).pipe(
|
calculate(request: QuoteRequest): Observable<number | QuoteResult> {
|
||||||
map(responses => {
|
console.log('QuoteEstimatorService: Calculating quote...', request);
|
||||||
console.log('All responses:', responses);
|
if (request.items.length === 0) {
|
||||||
|
console.warn('QuoteEstimatorService: No items to calculate');
|
||||||
|
return of();
|
||||||
|
}
|
||||||
|
|
||||||
const validResponses = responses.filter(r => r.success);
|
return new Observable(observer => {
|
||||||
if (validResponses.length === 0 && responses.length > 0) {
|
const totalItems = request.items.length;
|
||||||
throw new Error('All calculations failed. Check backend connection.');
|
const allProgress: number[] = new Array(totalItems).fill(0);
|
||||||
}
|
const finalResponses: any[] = [];
|
||||||
|
let completedRequests = 0;
|
||||||
|
|
||||||
let totalPrice = 0;
|
const uploads = request.items.map((item, index) => {
|
||||||
let totalTime = 0;
|
const formData = new FormData();
|
||||||
let totalWeight = 0;
|
formData.append('file', item.file);
|
||||||
let setupCost = 10; // Base setup
|
// machine param removed - backend uses default active
|
||||||
|
|
||||||
validResponses.forEach(res => {
|
// Map material? Or trust frontend to send correct code?
|
||||||
totalPrice += res.data.cost.total;
|
// Since we fetch options now, we should send the code directly.
|
||||||
totalTime += res.data.print_time_seconds;
|
// But for backward compat/safety/mapping logic in mapMaterial, let's keep it or update it.
|
||||||
totalWeight += res.data.material_grams;
|
// If frontend sends 'PLA', mapMaterial returns 'pla_basic'.
|
||||||
|
// We should check if request.material is already a code from options.
|
||||||
|
// For now, let's assume request.material IS the code if it matches our new options,
|
||||||
|
// or fallback to mapper if it's old legacy string.
|
||||||
|
// Let's keep mapMaterial but update it to be smarter if needed, or rely on UploadForm to send correct codes.
|
||||||
|
// For now, let's use mapMaterial as safety, assuming frontend sends short codes 'PLA'.
|
||||||
|
// Wait, if we use dynamic options, the 'value' in select will be the 'code' from backend (e.g. 'PLA').
|
||||||
|
// Backend expects 'pla_basic' or just 'PLA'?
|
||||||
|
// QuoteController -> processRequest -> SlicerService.slice -> assumes 'filament' is a profile name like 'pla_basic'.
|
||||||
|
// So we MUST map 'PLA' to 'pla_basic' UNLESS backend options return 'pla_basic' as code.
|
||||||
|
// Backend OptionsController returns type.getMaterialCode() which is 'PLA'.
|
||||||
|
// So we still need mapping to slicer profile names.
|
||||||
|
|
||||||
|
formData.append('filament', this.mapMaterial(request.material));
|
||||||
|
formData.append('quality', this.mapQuality(request.quality));
|
||||||
|
|
||||||
|
// Send color for both modes if present, defaulting to Black
|
||||||
|
formData.append('material_color', item.color || 'Black');
|
||||||
|
|
||||||
|
if (request.mode === 'advanced') {
|
||||||
|
if (request.infillDensity) formData.append('infill_density', request.infillDensity.toString());
|
||||||
|
if (request.infillPattern) formData.append('infill_pattern', request.infillPattern);
|
||||||
|
if (request.supportEnabled) formData.append('support_enabled', 'true');
|
||||||
|
if (request.layerHeight) formData.append('layer_height', request.layerHeight.toString());
|
||||||
|
if (request.nozzleDiameter) formData.append('nozzle_diameter', request.nozzleDiameter.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: any = {};
|
||||||
|
// @ts-ignore
|
||||||
|
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
|
||||||
|
|
||||||
|
return this.http.post<BackendResponse | BackendQuoteResult>(`${environment.apiUrl}/api/quote`, formData, {
|
||||||
|
headers,
|
||||||
|
reportProgress: true,
|
||||||
|
observe: 'events'
|
||||||
|
}).pipe(
|
||||||
|
map(event => ({ item, event, index })),
|
||||||
|
catchError(err => of({ item, error: err, index }))
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply quantity multiplier
|
// Subscribe to all
|
||||||
totalPrice = (totalPrice * request.quantity) + setupCost;
|
uploads.forEach((obs) => {
|
||||||
totalWeight = totalWeight * request.quantity;
|
obs.subscribe({
|
||||||
// Total time usually parallel if we have multiple printers, but let's sum for now
|
next: (wrapper: any) => {
|
||||||
totalTime = totalTime * request.quantity;
|
const idx = wrapper.index;
|
||||||
|
|
||||||
const totalHours = Math.floor(totalTime / 3600);
|
if (wrapper.error) {
|
||||||
const totalMinutes = Math.ceil((totalTime % 3600) / 60);
|
finalResponses[idx] = { success: false, fileName: wrapper.item.file.name };
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
const event = wrapper.event;
|
||||||
price: Math.round(totalPrice * 100) / 100, // Keep 2 decimals
|
if (event && event.type === HttpEventType.UploadProgress) {
|
||||||
currency: 'CHF',
|
if (event.total) {
|
||||||
printTimeHours: totalHours,
|
const percent = Math.round((100 * event.loaded) / event.total);
|
||||||
printTimeMinutes: totalMinutes,
|
allProgress[idx] = percent;
|
||||||
materialUsageGrams: Math.ceil(totalWeight),
|
// Emit average progress
|
||||||
setupCost
|
const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems);
|
||||||
};
|
observer.next(avg);
|
||||||
})
|
}
|
||||||
);
|
} else if ((event && event.type === HttpEventType.Response) || wrapper.error) {
|
||||||
|
// It's done (either response or error caught above)
|
||||||
|
if (!finalResponses[idx]) { // only if not already set by error
|
||||||
|
allProgress[idx] = 100;
|
||||||
|
if (wrapper.error) {
|
||||||
|
finalResponses[idx] = { success: false, fileName: wrapper.item.file.name };
|
||||||
|
} else {
|
||||||
|
finalResponses[idx] = { ...event.body, fileName: wrapper.item.file.name, originalQty: wrapper.item.quantity };
|
||||||
|
}
|
||||||
|
completedRequests++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completedRequests === totalItems) {
|
||||||
|
// All done
|
||||||
|
observer.next(100);
|
||||||
|
|
||||||
|
// Calculate Results
|
||||||
|
let setupCost = 10;
|
||||||
|
let setupCostFromBackend: number | null = null;
|
||||||
|
let currencyFromBackend: string | null = null;
|
||||||
|
|
||||||
|
if (request.nozzleDiameter && request.nozzleDiameter !== 0.4) {
|
||||||
|
setupCost += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: QuoteItem[] = [];
|
||||||
|
|
||||||
|
finalResponses.forEach((res, idx) => {
|
||||||
|
if (!res) return;
|
||||||
|
const originalItem = request.items[idx];
|
||||||
|
const normalized = this.normalizeResponse(res);
|
||||||
|
if (!normalized.success) return;
|
||||||
|
|
||||||
|
if (normalized.currency && currencyFromBackend == null) {
|
||||||
|
currencyFromBackend = normalized.currency;
|
||||||
|
}
|
||||||
|
if (normalized.setupCost != null && setupCostFromBackend == null) {
|
||||||
|
setupCostFromBackend = normalized.setupCost;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
fileName: res.fileName,
|
||||||
|
unitPrice: normalized.unitPrice,
|
||||||
|
unitTime: normalized.unitTime,
|
||||||
|
unitWeight: normalized.unitWeight,
|
||||||
|
quantity: res.originalQty, // Use the requested quantity
|
||||||
|
material: request.material,
|
||||||
|
color: originalItem.color || 'Default'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
observer.error('All calculations failed.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial Aggregation
|
||||||
|
const useBackendSetup = setupCostFromBackend != null;
|
||||||
|
let grandTotal = useBackendSetup ? 0 : setupCost;
|
||||||
|
let totalTime = 0;
|
||||||
|
let totalWeight = 0;
|
||||||
|
|
||||||
|
items.forEach(item => {
|
||||||
|
grandTotal += item.unitPrice * item.quantity;
|
||||||
|
totalTime += item.unitTime * item.quantity;
|
||||||
|
totalWeight += item.unitWeight * item.quantity;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalHours = Math.floor(totalTime / 3600);
|
||||||
|
const totalMinutes = Math.ceil((totalTime % 3600) / 60);
|
||||||
|
|
||||||
|
const result: QuoteResult = {
|
||||||
|
items,
|
||||||
|
setupCost: useBackendSetup ? setupCostFromBackend! : setupCost,
|
||||||
|
currency: currencyFromBackend || 'CHF',
|
||||||
|
totalPrice: Math.round(grandTotal * 100) / 100,
|
||||||
|
totalTimeHours: totalHours,
|
||||||
|
totalTimeMinutes: totalMinutes,
|
||||||
|
totalWeight: Math.ceil(totalWeight),
|
||||||
|
notes: request.notes
|
||||||
|
};
|
||||||
|
|
||||||
|
observer.next(result);
|
||||||
|
observer.complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Error in request subscription', err);
|
||||||
|
completedRequests++;
|
||||||
|
if (completedRequests === totalItems) {
|
||||||
|
observer.error('Requests failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeResponse(res: any): { success: boolean; unitPrice: number; unitTime: number; unitWeight: number; setupCost?: number; currency?: string } {
|
||||||
|
if (res && typeof res.totalPrice === 'number' && res.stats && typeof res.stats.printTimeSeconds === 'number') {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
unitPrice: res.totalPrice,
|
||||||
|
unitTime: res.stats.printTimeSeconds,
|
||||||
|
unitWeight: res.stats.filamentWeightGrams,
|
||||||
|
setupCost: res.setupCost,
|
||||||
|
currency: res.currency
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res && res.success && res.data) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
unitPrice: res.data.cost.total,
|
||||||
|
unitTime: res.data.print_time_seconds,
|
||||||
|
unitWeight: res.data.material_grams,
|
||||||
|
currency: 'CHF'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, unitPrice: 0, unitTime: 0, unitWeight: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapMaterial(mat: string): string {
|
private mapMaterial(mat: string): string {
|
||||||
@@ -134,4 +339,17 @@ export class QuoteEstimatorService {
|
|||||||
if (q.includes('high')) return 'extra_fine';
|
if (q.includes('high')) return 'extra_fine';
|
||||||
return 'standard';
|
return 'standard';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Consultation Data Transfer
|
||||||
|
private pendingConsultation = signal<{files: File[], message: string} | null>(null);
|
||||||
|
|
||||||
|
setPendingConsultation(data: {files: File[], message: string}) {
|
||||||
|
this.pendingConsultation.set(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPendingConsultation() {
|
||||||
|
const data = this.pendingConsultation();
|
||||||
|
this.pendingConsultation.set(null); // Clear after reading
|
||||||
|
return data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
@if (sent()) {
|
||||||
|
<app-success-state context="contact" (action)="resetForm()"></app-success-state>
|
||||||
|
} @else {
|
||||||
|
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||||
|
<!-- Request Type -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{{ 'CONTACT.REQ_TYPE_LABEL' | translate }} *</label>
|
||||||
|
<select formControlName="requestType" class="form-control">
|
||||||
|
<option *ngFor="let type of requestTypes" [value]="type.value">
|
||||||
|
{{ type.label | translate }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Phone -->
|
||||||
|
<app-input formControlName="email" type="email" [label]="'CONTACT.LABEL_EMAIL' | translate" [placeholder]="'CONTACT.PLACEHOLDER_EMAIL' | translate" class="col"></app-input>
|
||||||
|
<!-- Phone -->
|
||||||
|
<app-input formControlName="phone" type="tel" [label]="('CONTACT.PHONE' | translate)" [placeholder]="'CONTACT.PLACEHOLDER_PHONE' | translate" class="col"></app-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Type Selector (Segmented Control) -->
|
||||||
|
<div class="user-type-selector">
|
||||||
|
<div class="type-option" [class.selected]="!isCompany" (click)="setCompanyMode(false)">
|
||||||
|
{{ 'CONTACT.TYPE_PRIVATE' | translate }}
|
||||||
|
</div>
|
||||||
|
<div class="type-option" [class.selected]="isCompany" (click)="setCompanyMode(true)">
|
||||||
|
{{ 'CONTACT.TYPE_COMPANY' | translate }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Personal Name (Only if NOT Company) -->
|
||||||
|
<app-input *ngIf="!isCompany" formControlName="name" [label]="'CONTACT.LABEL_NAME' | translate" [placeholder]="'CONTACT.PLACEHOLDER_NAME' | translate"></app-input>
|
||||||
|
|
||||||
|
<!-- Company Fields (Only if Company) -->
|
||||||
|
<div *ngIf="isCompany" class="company-fields">
|
||||||
|
<app-input formControlName="companyName" [label]="('CONTACT.COMPANY_NAME' | translate) + ' *'" [placeholder]="'CONTACT.PLACEHOLDER_COMPANY' | translate"></app-input>
|
||||||
|
<app-input formControlName="referencePerson" [label]="('CONTACT.REF_PERSON' | translate) + ' *'" [placeholder]="'CONTACT.PLACEHOLDER_REF_PERSON' | translate"></app-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{{ 'CONTACT.LABEL_MESSAGE' | translate }}</label>
|
||||||
|
<textarea formControlName="message" class="form-control" rows="4"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File Upload Section -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{{ 'CONTACT.UPLOAD_LABEL' | translate }}</label>
|
||||||
|
<p class="hint">{{ 'CONTACT.UPLOAD_HINT' | translate }}</p>
|
||||||
|
|
||||||
|
<div class="drop-zone" (click)="fileInput.click()"
|
||||||
|
(dragover)="onDragOver($event)" (drop)="onDrop($event)">
|
||||||
|
<input #fileInput type="file" multiple (change)="onFileSelected($event)" hidden
|
||||||
|
accept=".jpg,.jpeg,.png,.pdf,.stl,.step,.stp,.3mf,.obj">
|
||||||
|
<p>{{ 'CONTACT.DROP_FILES' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="file-grid" *ngIf="files().length > 0">
|
||||||
|
<div class="file-item" *ngFor="let file of files(); let i = index">
|
||||||
|
<button type="button" class="remove-btn" (click)="removeFile(i)">×</button>
|
||||||
|
<img *ngIf="file.type === 'image'" [src]="file.url" class="preview-img">
|
||||||
|
<div *ngIf="file.type !== 'image'" class="file-icon">
|
||||||
|
<span *ngIf="file.type === 'pdf'">PDF</span>
|
||||||
|
<span *ngIf="file.type === '3d'">3D</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-name" [title]="file.file.name">{{ file.file.name }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<app-button type="submit" [disabled]="form.invalid || sent()">
|
||||||
|
{{ sent() ? ('CONTACT.MSG_SENT' | translate) : ('CONTACT.SEND' | translate) }}
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
.form-group { display: flex; flex-direction: column; margin-bottom: var(--space-4); }
|
||||||
|
label { font-size: 0.875rem; font-weight: 500; margin-bottom: var(--space-2); color: var(--color-text); }
|
||||||
|
.hint { font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: var(--space-2); }
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
width: 100%;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: inherit;
|
||||||
|
&:focus { outline: none; border-color: var(--color-brand); }
|
||||||
|
}
|
||||||
|
|
||||||
|
select.form-control {
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 1rem center;
|
||||||
|
background-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
@media(min-width: 768px) {
|
||||||
|
flex-direction: row;
|
||||||
|
.col { flex: 1; margin-bottom: 0; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app-input.col { width: 100%; }
|
||||||
|
|
||||||
|
/* User Type Selector Styles */
|
||||||
|
.user-type-selector {
|
||||||
|
display: flex;
|
||||||
|
background-color: var(--color-neutral-100);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 4px;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%; /* Full width */
|
||||||
|
max-width: 400px; /* Limit on desktop */
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-option {
|
||||||
|
flex: 1; /* Equal width */
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:hover { color: var(--color-text); }
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background-color: var(--color-brand);
|
||||||
|
color: #000;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding-left: var(--space-4);
|
||||||
|
border-left: 2px solid var(--color-border);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File Upload Styles */
|
||||||
|
.drop-zone {
|
||||||
|
border: 2px dashed var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-6);
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
transition: all 0.2s;
|
||||||
|
&:hover { border-color: var(--color-brand); color: var(--color-brand); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
position: relative;
|
||||||
|
background: var(--color-neutral-100);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: var(--space-2);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-img {
|
||||||
|
width: 100%; height: 100%; object-fit: cover; position: absolute; top:0; left:0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
font-weight: 700; color: var(--color-text-muted); font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-size: 0.65rem; color: var(--color-text); white-space: nowrap; overflow: hidden;
|
||||||
|
text-overflow: ellipsis; width: 100%; text-align: center; position: absolute; bottom: 2px;
|
||||||
|
padding: 0 4px; z-index: 2; background: rgba(255,255,255,0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn {
|
||||||
|
position: absolute; top: 2px; right: 2px; z-index: 10;
|
||||||
|
background: rgba(0,0,0,0.5); color: white; border: none; border-radius: 50%;
|
||||||
|
width: 18px; height: 18px; font-size: 12px; cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center; line-height: 1;
|
||||||
|
&:hover { background: red; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success State styles moved to shared component */
|
||||||
@@ -4,6 +4,7 @@ import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angula
|
|||||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||||
import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component';
|
import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component';
|
||||||
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
|
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
|
||||||
|
import { QuoteEstimatorService } from '../../../calculator/services/quote-estimator.service';
|
||||||
|
|
||||||
interface FilePreview {
|
interface FilePreview {
|
||||||
file: File;
|
file: File;
|
||||||
@@ -11,220 +12,14 @@ interface FilePreview {
|
|||||||
type: 'image' | 'pdf' | '3d' | 'other';
|
type: 'image' | 'pdf' | '3d' | 'other';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { SuccessStateComponent } from '../../../../shared/components/success-state/success-state.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-contact-form',
|
selector: 'app-contact-form',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppButtonComponent],
|
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppButtonComponent, SuccessStateComponent],
|
||||||
template: `
|
templateUrl: './contact-form.component.html',
|
||||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
styleUrl: './contact-form.component.scss'
|
||||||
<!-- Request Type -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label>{{ 'CONTACT.REQ_TYPE_LABEL' | translate }} *</label>
|
|
||||||
<select formControlName="requestType" class="form-control">
|
|
||||||
<option *ngFor="let type of requestTypes" [value]="type.value">
|
|
||||||
{{ type.label | translate }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<!-- Phone -->
|
|
||||||
<app-input formControlName="email" type="email" [label]="'CONTACT.LABEL_EMAIL' | translate" [placeholder]="'CONTACT.PLACEHOLDER_EMAIL' | translate" class="col"></app-input>
|
|
||||||
<!-- Phone -->
|
|
||||||
<app-input formControlName="phone" type="tel" [label]="('CONTACT.PHONE' | translate)" [placeholder]="'CONTACT.PLACEHOLDER_PHONE' | translate" class="col"></app-input>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- User Type Selector (Segmented Control) -->
|
|
||||||
<div class="user-type-selector">
|
|
||||||
<div class="type-option" [class.selected]="!isCompany" (click)="setCompanyMode(false)">
|
|
||||||
{{ 'CONTACT.TYPE_PRIVATE' | translate }}
|
|
||||||
</div>
|
|
||||||
<div class="type-option" [class.selected]="isCompany" (click)="setCompanyMode(true)">
|
|
||||||
{{ 'CONTACT.TYPE_COMPANY' | translate }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Personal Name (Only if NOT Company) -->
|
|
||||||
<app-input *ngIf="!isCompany" formControlName="name" [label]="'CONTACT.LABEL_NAME' | translate" [placeholder]="'CONTACT.PLACEHOLDER_NAME' | translate"></app-input>
|
|
||||||
|
|
||||||
<!-- Company Fields (Only if Company) -->
|
|
||||||
<div *ngIf="isCompany" class="company-fields">
|
|
||||||
<app-input formControlName="companyName" [label]="('CONTACT.COMPANY_NAME' | translate) + ' *'" [placeholder]="'CONTACT.PLACEHOLDER_COMPANY' | translate"></app-input>
|
|
||||||
<app-input formControlName="referencePerson" [label]="('CONTACT.REF_PERSON' | translate) + ' *'" [placeholder]="'CONTACT.PLACEHOLDER_REF_PERSON' | translate"></app-input>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>{{ 'CONTACT.LABEL_MESSAGE' | translate }}</label>
|
|
||||||
<textarea formControlName="message" class="form-control" rows="4"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- File Upload Section -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label>{{ 'CONTACT.UPLOAD_LABEL' | translate }}</label>
|
|
||||||
<p class="hint">{{ 'CONTACT.UPLOAD_HINT' | translate }}</p>
|
|
||||||
|
|
||||||
<div class="drop-zone" (click)="fileInput.click()"
|
|
||||||
(dragover)="onDragOver($event)" (drop)="onDrop($event)">
|
|
||||||
<input #fileInput type="file" multiple (change)="onFileSelected($event)" hidden
|
|
||||||
accept=".jpg,.jpeg,.png,.pdf,.stl,.step,.stp,.3mf,.obj">
|
|
||||||
<p>{{ 'CONTACT.DROP_FILES' | translate }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="file-grid" *ngIf="files().length > 0">
|
|
||||||
<div class="file-item" *ngFor="let file of files(); let i = index">
|
|
||||||
<button type="button" class="remove-btn" (click)="removeFile(i)">×</button>
|
|
||||||
<img *ngIf="file.type === 'image'" [src]="file.url" class="preview-img">
|
|
||||||
<div *ngIf="file.type !== 'image'" class="file-icon">
|
|
||||||
<span *ngIf="file.type === 'pdf'">PDF</span>
|
|
||||||
<span *ngIf="file.type === '3d'">3D</span>
|
|
||||||
</div>
|
|
||||||
<div class="file-name" [title]="file.file.name">{{ file.file.name }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
<app-button type="submit" [disabled]="form.invalid || sent()">
|
|
||||||
{{ sent() ? ('CONTACT.MSG_SENT' | translate) : ('CONTACT.SEND' | translate) }}
|
|
||||||
</app-button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
`,
|
|
||||||
styles: [`
|
|
||||||
.form-group { display: flex; flex-direction: column; margin-bottom: var(--space-4); }
|
|
||||||
label { font-size: 0.875rem; font-weight: 500; margin-bottom: var(--space-2); color: var(--color-text); }
|
|
||||||
.hint { font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: var(--space-2); }
|
|
||||||
|
|
||||||
.form-control {
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
width: 100%;
|
|
||||||
background: var(--color-bg-card);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-family: inherit;
|
|
||||||
&:focus { outline: none; border-color: var(--color-brand); }
|
|
||||||
}
|
|
||||||
|
|
||||||
select.form-control {
|
|
||||||
appearance: none;
|
|
||||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: right 1rem center;
|
|
||||||
background-size: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-4);
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
@media(min-width: 768px) {
|
|
||||||
flex-direction: row;
|
|
||||||
.col { flex: 1; margin-bottom: 0; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
app-input.col { width: 100%; }
|
|
||||||
|
|
||||||
/* User Type Selector Styles */
|
|
||||||
.user-type-selector {
|
|
||||||
display: flex;
|
|
||||||
background-color: var(--color-neutral-100);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 4px;
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
gap: 4px;
|
|
||||||
width: 100%; /* Full width */
|
|
||||||
max-width: 400px; /* Limit on desktop */
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-option {
|
|
||||||
flex: 1; /* Equal width */
|
|
||||||
text-align: center;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
&:hover { color: var(--color-text); }
|
|
||||||
|
|
||||||
&.selected {
|
|
||||||
background-color: var(--color-brand);
|
|
||||||
color: #000;
|
|
||||||
font-weight: 600;
|
|
||||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.company-fields {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-4);
|
|
||||||
padding-left: var(--space-4);
|
|
||||||
border-left: 2px solid var(--color-border);
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* File Upload Styles */
|
|
||||||
.drop-zone {
|
|
||||||
border: 2px dashed var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: var(--space-6);
|
|
||||||
text-align: center;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
transition: all 0.2s;
|
|
||||||
&:hover { border-color: var(--color-brand); color: var(--color-brand); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
|
||||||
gap: var(--space-3);
|
|
||||||
margin-top: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-item {
|
|
||||||
position: relative;
|
|
||||||
background: var(--color-neutral-100);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: var(--space-2);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
aspect-ratio: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-img {
|
|
||||||
width: 100%; height: 100%; object-fit: cover; position: absolute; top:0; left:0;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-icon {
|
|
||||||
font-weight: 700; color: var(--color-text-muted); font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-name {
|
|
||||||
font-size: 0.65rem; color: var(--color-text); white-space: nowrap; overflow: hidden;
|
|
||||||
text-overflow: ellipsis; width: 100%; text-align: center; position: absolute; bottom: 2px;
|
|
||||||
padding: 0 4px; z-index: 2; background: rgba(255,255,255,0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.remove-btn {
|
|
||||||
position: absolute; top: 2px; right: 2px; z-index: 10;
|
|
||||||
background: rgba(0,0,0,0.5); color: white; border: none; border-radius: 50%;
|
|
||||||
width: 18px; height: 18px; font-size: 12px; cursor: pointer;
|
|
||||||
display: flex; align-items: center; justify-content: center; line-height: 1;
|
|
||||||
&:hover { background: red; }
|
|
||||||
}
|
|
||||||
`]
|
|
||||||
})
|
})
|
||||||
export class ContactFormComponent {
|
export class ContactFormComponent {
|
||||||
form: FormGroup;
|
form: FormGroup;
|
||||||
@@ -242,7 +37,11 @@ export class ContactFormComponent {
|
|||||||
{ value: 'question', label: 'CONTACT.REQ_TYPE_QUESTION' }
|
{ value: 'question', label: 'CONTACT.REQ_TYPE_QUESTION' }
|
||||||
];
|
];
|
||||||
|
|
||||||
constructor(private fb: FormBuilder, private translate: TranslateService) {
|
constructor(
|
||||||
|
private fb: FormBuilder,
|
||||||
|
private translate: TranslateService,
|
||||||
|
private estimator: QuoteEstimatorService
|
||||||
|
) {
|
||||||
this.form = this.fb.group({
|
this.form = this.fb.group({
|
||||||
requestType: ['custom', Validators.required],
|
requestType: ['custom', Validators.required],
|
||||||
name: ['', Validators.required],
|
name: ['', Validators.required],
|
||||||
@@ -279,6 +78,27 @@ export class ContactFormComponent {
|
|||||||
companyNameControl?.updateValueAndValidity();
|
companyNameControl?.updateValueAndValidity();
|
||||||
refPersonControl?.updateValueAndValidity();
|
refPersonControl?.updateValueAndValidity();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check for pending consultation data
|
||||||
|
effect(() => {
|
||||||
|
// Use timeout or run in constructor to ensure dependency availability?
|
||||||
|
// Actually best in constructor or ngOnInit. Let's stick to constructor logic but executed immediately.
|
||||||
|
});
|
||||||
|
|
||||||
|
const pending = this.estimator.getPendingConsultation();
|
||||||
|
if (pending) {
|
||||||
|
this.form.patchValue({
|
||||||
|
requestType: 'consult',
|
||||||
|
message: pending.message
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process files
|
||||||
|
const filePreviews: FilePreview[] = [];
|
||||||
|
pending.files.forEach(f => {
|
||||||
|
filePreviews.push({ file: f, type: this.getFileType(f) });
|
||||||
|
});
|
||||||
|
this.files.set(filePreviews);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setCompanyMode(isCompany: boolean) {
|
setCompanyMode(isCompany: boolean) {
|
||||||
@@ -343,13 +163,14 @@ export class ContactFormComponent {
|
|||||||
console.log('Form Submit:', formData);
|
console.log('Form Submit:', formData);
|
||||||
|
|
||||||
this.sent.set(true);
|
this.sent.set(true);
|
||||||
setTimeout(() => {
|
|
||||||
this.sent.set(false);
|
|
||||||
this.form.reset({ requestType: 'custom', isCompany: false });
|
|
||||||
this.files.set([]);
|
|
||||||
}, 3000);
|
|
||||||
} else {
|
} else {
|
||||||
this.form.markAllAsTouched();
|
this.form.markAllAsTouched();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resetForm() {
|
||||||
|
this.sent.set(false);
|
||||||
|
this.form.reset({ requestType: 'custom', isCompany: false });
|
||||||
|
this.files.set([]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<section class="contact-hero">
|
||||||
|
<div class="container">
|
||||||
|
<h1>{{ 'CONTACT.TITLE' | translate }}</h1>
|
||||||
|
<p class="subtitle">Siamo qui per aiutarti. Compila il modulo sottostante per qualsiasi richiesta.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="container content">
|
||||||
|
<app-card>
|
||||||
|
<app-contact-form></app-contact-form>
|
||||||
|
</app-card>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
.contact-hero {
|
||||||
|
padding: 3rem 0 2rem;
|
||||||
|
background: var(--color-bg);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
max-width: 640px;
|
||||||
|
margin: var(--space-3) auto 0;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 2rem 0 5rem;
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
@@ -8,35 +8,7 @@ import { AppCardComponent } from '../../shared/components/app-card/app-card.comp
|
|||||||
selector: 'app-contact-page',
|
selector: 'app-contact-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, TranslateModule, ContactFormComponent, AppCardComponent],
|
imports: [CommonModule, TranslateModule, ContactFormComponent, AppCardComponent],
|
||||||
template: `
|
templateUrl: './contact-page.component.html',
|
||||||
<section class="contact-hero">
|
styleUrl: './contact-page.component.scss'
|
||||||
<div class="container">
|
|
||||||
<h1>{{ 'CONTACT.TITLE' | translate }}</h1>
|
|
||||||
<p class="subtitle">Siamo qui per aiutarti. Compila il modulo sottostante per qualsiasi richiesta.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="container content">
|
|
||||||
<app-card>
|
|
||||||
<app-contact-form></app-contact-form>
|
|
||||||
</app-card>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
styles: [`
|
|
||||||
.contact-hero {
|
|
||||||
padding: 3rem 0 2rem;
|
|
||||||
background: var(--color-bg);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.subtitle {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
max-width: 640px;
|
|
||||||
margin: var(--space-3) auto 0;
|
|
||||||
}
|
|
||||||
.content {
|
|
||||||
padding: 2rem 0 5rem;
|
|
||||||
max-width: 800px;
|
|
||||||
}
|
|
||||||
`]
|
|
||||||
})
|
})
|
||||||
export class ContactPageComponent {}
|
export class ContactPageComponent {}
|
||||||
|
|||||||
@@ -7,13 +7,17 @@
|
|||||||
Prezzo e tempi in pochi secondi.<br>
|
Prezzo e tempi in pochi secondi.<br>
|
||||||
Dal file 3D al pezzo finito.
|
Dal file 3D al pezzo finito.
|
||||||
</h1>
|
</h1>
|
||||||
|
<p class="hero-lead">
|
||||||
|
Il calcolatore più avanzato per le tue stampe 3D: precisione assoluta e zero sorprese.
|
||||||
|
</p>
|
||||||
<p class="hero-subtitle">
|
<p class="hero-subtitle">
|
||||||
Lavoriamo con trasparenza su costi, qualità e tempi. Produciamo prototipi, pezzi personalizzati
|
Realizziamo pezzi unici e produzioni in serie. Se hai il file, il preventivo è istantaneo.
|
||||||
e piccole serie con supporto tecnico reale.
|
Se devi ancora crearlo, il nostro team di design lo progetterà per te.
|
||||||
</p>
|
</p>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<app-button variant="primary" routerLink="/about">Parla con noi</app-button>
|
<app-button variant="primary" routerLink="/calculator/basic">Calcola Preventivo</app-button>
|
||||||
<app-button variant="outline" routerLink="/shop">Vai allo shop</app-button>
|
<app-button variant="outline" routerLink="/shop">Vai allo shop</app-button>
|
||||||
|
<app-button variant="text" routerLink="/contact">Parla con noi</app-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -22,13 +26,12 @@
|
|||||||
<section class="section calculator">
|
<section class="section calculator">
|
||||||
<div class="container calculator-grid">
|
<div class="container calculator-grid">
|
||||||
<div class="calculator-copy">
|
<div class="calculator-copy">
|
||||||
<h2 class="section-title">Preventivo immediato</h2>
|
<h2 class="section-title">Preventivo immediato in pochi secondi</h2>
|
||||||
<p class="section-subtitle">
|
<p class="section-subtitle">
|
||||||
Carica il file 3D e ottieni subito costo e tempo di stampa. Nessuna registrazione.
|
Nessuna registrazione necessaria. Non salviamo il tuo file fino al momento dell'ordine e la stima è effettuata tramite vero slicing.
|
||||||
</p>
|
</p>
|
||||||
<ul class="calculator-list">
|
<ul class="calculator-list">
|
||||||
<li>Formati supportati: STL, 3MF, STEP, OBJ</li>
|
<li>Formati supportati: STL, 3MF, STEP, OBJ</li>
|
||||||
<li>Materiali disponibili: PLA, PETG, TPU</li>
|
|
||||||
<li>Qualità: bozza, standard, alta definizione</li>
|
<li>Qualità: bozza, standard, alta definizione</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -45,19 +48,9 @@
|
|||||||
<li>Scegli materiale e qualità</li>
|
<li>Scegli materiale e qualità</li>
|
||||||
<li>Ricevi subito costo e tempo</li>
|
<li>Ricevi subito costo e tempo</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="quote-meta">
|
|
||||||
<div>
|
|
||||||
<span class="meta-label">Modalità</span>
|
|
||||||
<span class="meta-value">Rapida / Avanzata</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="meta-label">Output</span>
|
|
||||||
<span class="meta-value">Ordina o richiedi consulenza</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="quote-actions">
|
<div class="quote-actions">
|
||||||
<app-button variant="primary" [fullWidth]="true" routerLink="/cal">Apri calcolatore</app-button>
|
<app-button variant="primary" [fullWidth]="true" routerLink="/calculator/basic">Apri calcolatore</app-button>
|
||||||
<app-button variant="outline" [fullWidth]="true" routerLink="/about">Parla con noi</app-button>
|
<app-button variant="outline" [fullWidth]="true" routerLink="/contact">Parla con noi</app-button>
|
||||||
</div>
|
</div>
|
||||||
</app-card>
|
</app-card>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,20 +67,32 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="cap-cards">
|
<div class="cap-cards">
|
||||||
<app-card>
|
<app-card>
|
||||||
|
<div class="card-image-placeholder">
|
||||||
|
<!-- <img src="..." alt="..."> -->
|
||||||
|
</div>
|
||||||
<h3>Prototipazione veloce</h3>
|
<h3>Prototipazione veloce</h3>
|
||||||
<p class="text-muted">Valida idee e funzioni in pochi giorni con preventivo immediato.</p>
|
<p class="text-muted">Dal file digitale al modello fisico in 24/48 ore. Verifica ergonomia, incastri e funzionamento prima dello stampo definitivo.</p>
|
||||||
</app-card>
|
</app-card>
|
||||||
<app-card>
|
<app-card>
|
||||||
|
<div class="card-image-placeholder">
|
||||||
|
<!-- <img src="..." alt="..."> -->
|
||||||
|
</div>
|
||||||
<h3>Pezzi personalizzati</h3>
|
<h3>Pezzi personalizzati</h3>
|
||||||
<p class="text-muted">Componenti unici o in mini serie per clienti, macchine e prodotti.</p>
|
<p class="text-muted">Componenti unici impossibili da trovare in commercio. Riproduciamo parti rotte o creiamo adattamenti su misura.</p>
|
||||||
</app-card>
|
</app-card>
|
||||||
<app-card>
|
<app-card>
|
||||||
|
<div class="card-image-placeholder">
|
||||||
|
<!-- <img src="..." alt="..."> -->
|
||||||
|
</div>
|
||||||
<h3>Piccole serie</h3>
|
<h3>Piccole serie</h3>
|
||||||
<p class="text-muted">Produzione controllata fino a 500 pezzi con qualità costante.</p>
|
<p class="text-muted">Produzione ponte o serie limitate (10-500 pezzi) con qualità ripetibile. Ideale per validazione di mercato.</p>
|
||||||
</app-card>
|
</app-card>
|
||||||
<app-card>
|
<app-card>
|
||||||
|
<div class="card-image-placeholder">
|
||||||
|
<!-- <img src="..." alt="..."> -->
|
||||||
|
</div>
|
||||||
<h3>Consulenza e CAD</h3>
|
<h3>Consulenza e CAD</h3>
|
||||||
<p class="text-muted">Supporto tecnico per progettazione, modifiche e ottimizzazione.</p>
|
<p class="text-muted">Non hai il file 3D? Ti aiutiamo a progettarlo, ottimizzarlo per la stampa e scegliere il materiale giusto.</p>
|
||||||
</app-card>
|
</app-card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,7 +113,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<div class="shop-actions">
|
<div class="shop-actions">
|
||||||
<app-button variant="primary" routerLink="/shop">Scopri i prodotti</app-button>
|
<app-button variant="primary" routerLink="/shop">Scopri i prodotti</app-button>
|
||||||
<app-button variant="outline" routerLink="/about">Richiedi una soluzione</app-button>
|
<app-button variant="outline" routerLink="/contact">Richiedi una soluzione</app-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="shop-cards">
|
<div class="shop-cards">
|
||||||
@@ -136,25 +141,12 @@
|
|||||||
3D fab è un laboratorio tecnico di stampa 3D. Seguiamo progetti dalla consulenza iniziale
|
3D fab è un laboratorio tecnico di stampa 3D. Seguiamo progetti dalla consulenza iniziale
|
||||||
alla produzione, con tempi chiari e supporto diretto.
|
alla produzione, con tempi chiari e supporto diretto.
|
||||||
</p>
|
</p>
|
||||||
<p class="text-muted">
|
<app-button variant="outline" routerLink="/contact">Contattaci</app-button>
|
||||||
Qui puoi inserire descrizioni più dettagliate del team, del laboratorio e dei progetti in corso.
|
|
||||||
</p>
|
|
||||||
<app-button variant="outline" routerLink="/about">Contattaci</app-button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="about-media">
|
<div class="about-media">
|
||||||
<div class="media-grid">
|
<div class="about-feature-image">
|
||||||
<div class="media-tile">
|
<!-- Foto founders -->
|
||||||
<div class="media-photo"></div>
|
<span class="text-sm">Foto Founders</span>
|
||||||
<p>Foto laboratorio / stampanti</p>
|
|
||||||
</div>
|
|
||||||
<div class="media-tile">
|
|
||||||
<div class="media-photo"></div>
|
|
||||||
<p>Dettagli qualità e finiture</p>
|
|
||||||
</div>
|
|
||||||
<div class="media-tile">
|
|
||||||
<div class="media-photo"></div>
|
|
||||||
<p>Team, prototipi o casi studio</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,10 +15,10 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@include patterns.pattern-grid(var(--color-neutral-900), 40px, 1px);
|
@include patterns.pattern-grid(var(--color-neutral-900), 40px, 1px);
|
||||||
opacity: 0.12;
|
opacity: 0.06;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
mask-image: linear-gradient(to bottom, black 40%, transparent 100%);
|
mask-image: linear-gradient(to bottom, black 70%, transparent 100%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +43,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-copy { animation: fadeUp 0.8s ease both; }
|
.hero-copy { animation: fadeUp 0.8s ease both; }
|
||||||
.hero-panel { animation: fadeUp 0.8s ease 0.15s both; }
|
.hero-panel { animation: fadeUp 0.8s ease 0.15s both; }
|
||||||
|
|
||||||
@@ -61,10 +62,18 @@
|
|||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
margin-bottom: var(--space-4);
|
margin-bottom: var(--space-4);
|
||||||
}
|
}
|
||||||
|
.hero-lead {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
.hero-subtitle {
|
.hero-subtitle {
|
||||||
font-size: 1.2rem;
|
font-size: 1.1rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
max-width: 560px;
|
max-width: 560px;
|
||||||
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
.hero-actions {
|
.hero-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -135,6 +144,9 @@
|
|||||||
padding: 0.35rem 0.75rem;
|
padding: 0.35rem 0.75rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
color: var(--color-brand-600);
|
||||||
|
background: var(--color-brand-50);
|
||||||
|
border-color: var(--color-brand-200);
|
||||||
}
|
}
|
||||||
.quote-steps {
|
.quote-steps {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -177,14 +189,10 @@
|
|||||||
|
|
||||||
.capabilities {
|
.capabilities {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
.capabilities-bg {
|
.capabilities-bg {
|
||||||
position: absolute;
|
display: none;
|
||||||
inset: 0;
|
|
||||||
@include patterns.pattern-rectilinear(var(--color-neutral-900), 24px, 1px);
|
|
||||||
opacity: 0.05;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section { padding: 5.5rem 0; position: relative; }
|
.section { padding: 5.5rem 0; position: relative; }
|
||||||
@@ -194,24 +202,13 @@
|
|||||||
.text-muted { color: var(--color-text-muted); }
|
.text-muted { color: var(--color-text-muted); }
|
||||||
|
|
||||||
.calculator {
|
.calculator {
|
||||||
background: var(--color-neutral-50);
|
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
position: relative;
|
position: relative;
|
||||||
// Honeycomb Pattern
|
border-bottom: 1px solid var(--color-border);
|
||||||
&::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
@include patterns.pattern-honeycomb(var(--color-neutral-900), 24px);
|
|
||||||
opacity: 0.04;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.calculator-grid {
|
.calculator-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-10);
|
gap: var(--space-10);
|
||||||
align-items: center;
|
align-items: start;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
@@ -226,6 +223,19 @@
|
|||||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-image-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 160px;
|
||||||
|
background: var(--color-neutral-100);
|
||||||
|
margin: -1.5rem -1.5rem 1.5rem -1.5rem; /* Negative margins to bleed to edge */
|
||||||
|
width: calc(100% + 3rem);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-neutral-400);
|
||||||
|
}
|
||||||
|
|
||||||
.shop {
|
.shop {
|
||||||
background: var(--color-neutral-50);
|
background: var(--color-neutral-50);
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -282,24 +292,21 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.about-media {
|
.about-media {
|
||||||
display: grid;
|
position: relative;
|
||||||
gap: var(--space-4);
|
|
||||||
}
|
}
|
||||||
.media-grid {
|
|
||||||
display: grid;
|
.about-feature-image {
|
||||||
gap: var(--space-4);
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
||||||
}
|
|
||||||
.media-tile {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
.media-photo {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 4 / 3;
|
height: 100%;
|
||||||
|
min-height: 320px;
|
||||||
|
object-fit: cover;
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
background: var(--color-neutral-100);
|
background: var(--color-neutral-100);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
.media-tile p {
|
.media-tile p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -313,6 +320,7 @@
|
|||||||
@media (min-width: 960px) {
|
@media (min-width: 960px) {
|
||||||
.hero-grid { grid-template-columns: 1.1fr 0.9fr; }
|
.hero-grid { grid-template-columns: 1.1fr 0.9fr; }
|
||||||
.calculator-grid { grid-template-columns: 1.1fr 0.9fr; }
|
.calculator-grid { grid-template-columns: 1.1fr 0.9fr; }
|
||||||
|
.calculator-grid { grid-template-columns: 1.1fr 0.9fr; }
|
||||||
.split { grid-template-columns: 1.1fr 0.9fr; }
|
.split { grid-template-columns: 1.1fr 0.9fr; }
|
||||||
.about-grid { grid-template-columns: 1.1fr 0.9fr; }
|
.about-grid { grid-template-columns: 1.1fr 0.9fr; }
|
||||||
}
|
}
|
||||||
|
|||||||
12
frontend/src/app/features/legal/legal.routes.ts
Normal file
12
frontend/src/app/features/legal/legal.routes.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
|
||||||
|
export const LEGAL_ROUTES: Routes = [
|
||||||
|
{
|
||||||
|
path: 'privacy',
|
||||||
|
loadComponent: () => import('./privacy/privacy.component').then(m => m.PrivacyComponent)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'terms',
|
||||||
|
loadComponent: () => import('./terms/terms.component').then(m => m.TermsComponent)
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<section class="legal-page">
|
||||||
|
<div class="container narrow">
|
||||||
|
<h1>{{ 'LEGAL.PRIVACY_TITLE' | translate }}</h1>
|
||||||
|
<div class="content">
|
||||||
|
<p class="intro">{{ 'LEGAL.LAST_UPDATE' | translate }}: February 2026</p>
|
||||||
|
|
||||||
|
<h2>{{ 'LEGAL.PRIVACY.SECTION_1' | translate }}</h2>
|
||||||
|
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
|
||||||
|
|
||||||
|
<h2>{{ 'LEGAL.PRIVACY.SECTION_2' | translate }}</h2>
|
||||||
|
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
|
||||||
|
|
||||||
|
<h2>{{ 'LEGAL.PRIVACY.SECTION_3' | translate }}</h2>
|
||||||
|
<p>Curabitur pretium tincidunt lacus. Nulla gravida orci a odio. Nullam varius, turpis et commodo pharetra, est eros bibendum elit, nec luctus magna felis sollicitudin mauris.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
.legal-page {
|
||||||
|
padding: 6rem 0;
|
||||||
|
min-height: 70vh;
|
||||||
|
|
||||||
|
.narrow {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
color: var(--color-text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-top: 2.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--color-text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
line-height: 1.8;
|
||||||
|
color: var(--color-text-main);
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
frontend/src/app/features/legal/privacy/privacy.component.ts
Normal file
11
frontend/src/app/features/legal/privacy/privacy.component.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-privacy',
|
||||||
|
standalone: true,
|
||||||
|
imports: [TranslateModule],
|
||||||
|
templateUrl: './privacy.component.html',
|
||||||
|
styleUrl: './privacy.component.scss'
|
||||||
|
})
|
||||||
|
export class PrivacyComponent {}
|
||||||
18
frontend/src/app/features/legal/terms/terms.component.html
Normal file
18
frontend/src/app/features/legal/terms/terms.component.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<section class="legal-page">
|
||||||
|
<div class="container narrow">
|
||||||
|
<h1>{{ 'LEGAL.TERMS_TITLE' | translate }}</h1>
|
||||||
|
<div class="content">
|
||||||
|
<p class="intro">{{ 'LEGAL.LAST_UPDATE' | translate }}: February 2026</p>
|
||||||
|
|
||||||
|
<h2>{{ 'LEGAL.TERMS.SECTION_1' | translate }}</h2>
|
||||||
|
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
|
||||||
|
|
||||||
|
<h2>{{ 'LEGAL.TERMS.SECTION_2' | translate }}</h2>
|
||||||
|
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
|
||||||
|
|
||||||
|
<h2>{{ 'LEGAL.TERMS.SECTION_3' | translate }}</h2>
|
||||||
|
<p>I prodotti personalizzati e realizzati su misura tramite stampa 3D non sono soggetti al diritto di recesso, a meno di difetti di fabbricazione evidenti o errori rispetto al file fornito.</p>
|
||||||
|
<p>In caso di problemi, vi preghiamo di contattarci entro 14 giorni dalla ricezione per valutare una sostituzione o un rimborso parziale.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
37
frontend/src/app/features/legal/terms/terms.component.scss
Normal file
37
frontend/src/app/features/legal/terms/terms.component.scss
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
.legal-page {
|
||||||
|
padding: 6rem 0;
|
||||||
|
min-height: 70vh;
|
||||||
|
|
||||||
|
.narrow {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
color: var(--color-text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-top: 2.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--color-text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
line-height: 1.8;
|
||||||
|
color: var(--color-text-main);
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
frontend/src/app/features/legal/terms/terms.component.ts
Normal file
11
frontend/src/app/features/legal/terms/terms.component.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-terms',
|
||||||
|
standalone: true,
|
||||||
|
imports: [TranslateModule],
|
||||||
|
templateUrl: './terms.component.html',
|
||||||
|
styleUrl: './terms.component.scss'
|
||||||
|
})
|
||||||
|
export class TermsComponent {}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<div class="product-card">
|
||||||
|
<div class="image-placeholder"></div>
|
||||||
|
<div class="content">
|
||||||
|
<span class="category">{{ product().category }}</span>
|
||||||
|
<h3 class="name">
|
||||||
|
<a [routerLink]="['/shop', product().id]">{{ product().name }}</a>
|
||||||
|
</h3>
|
||||||
|
<div class="footer">
|
||||||
|
<span class="price">{{ product().price | currency:'EUR' }}</span>
|
||||||
|
<a [routerLink]="['/shop', product().id]" class="view-btn">Dettagli</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
.product-card {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: box-shadow 0.2s;
|
||||||
|
&:hover { box-shadow: var(--shadow-md); }
|
||||||
|
}
|
||||||
|
.image-placeholder {
|
||||||
|
height: 200px;
|
||||||
|
background-color: var(--color-neutral-200);
|
||||||
|
}
|
||||||
|
.content { padding: var(--space-4); }
|
||||||
|
.category { font-size: 0.75rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
.name { font-size: 1.125rem; margin: var(--space-2) 0; a { color: var(--color-text); text-decoration: none; &:hover { color: var(--color-brand); } } }
|
||||||
|
.footer { display: flex; justify-content: space-between; align-items: center; margin-top: var(--space-4); }
|
||||||
|
.price { font-weight: 700; color: var(--color-brand); }
|
||||||
|
.view-btn { font-size: 0.875rem; font-weight: 500; }
|
||||||
@@ -7,41 +7,8 @@ import { Product } from '../../services/shop.service';
|
|||||||
selector: 'app-product-card',
|
selector: 'app-product-card',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterLink],
|
imports: [CommonModule, RouterLink],
|
||||||
template: `
|
templateUrl: './product-card.component.html',
|
||||||
<div class="product-card">
|
styleUrl: './product-card.component.scss'
|
||||||
<div class="image-placeholder"></div>
|
|
||||||
<div class="content">
|
|
||||||
<span class="category">{{ product().category }}</span>
|
|
||||||
<h3 class="name">
|
|
||||||
<a [routerLink]="['/shop', product().id]">{{ product().name }}</a>
|
|
||||||
</h3>
|
|
||||||
<div class="footer">
|
|
||||||
<span class="price">{{ product().price | currency:'EUR' }}</span>
|
|
||||||
<a [routerLink]="['/shop', product().id]" class="view-btn">Dettagli</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
styles: [`
|
|
||||||
.product-card {
|
|
||||||
background: var(--color-bg-card);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
overflow: hidden;
|
|
||||||
transition: box-shadow 0.2s;
|
|
||||||
&:hover { box-shadow: var(--shadow-md); }
|
|
||||||
}
|
|
||||||
.image-placeholder {
|
|
||||||
height: 200px;
|
|
||||||
background-color: var(--color-neutral-200);
|
|
||||||
}
|
|
||||||
.content { padding: var(--space-4); }
|
|
||||||
.category { font-size: 0.75rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
|
||||||
.name { font-size: 1.125rem; margin: var(--space-2) 0; a { color: var(--color-text); text-decoration: none; &:hover { color: var(--color-brand); } } }
|
|
||||||
.footer { display: flex; justify-content: space-between; align-items: center; margin-top: var(--space-4); }
|
|
||||||
.price { font-weight: 700; color: var(--color-brand); }
|
|
||||||
.view-btn { font-size: 0.875rem; font-weight: 500; }
|
|
||||||
`]
|
|
||||||
})
|
})
|
||||||
export class ProductCardComponent {
|
export class ProductCardComponent {
|
||||||
product = input.required<Product>();
|
product = input.required<Product>();
|
||||||
|
|||||||
25
frontend/src/app/features/shop/product-detail.component.html
Normal file
25
frontend/src/app/features/shop/product-detail.component.html
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<div class="container wrapper">
|
||||||
|
<a routerLink="/shop" class="back-link">← {{ 'SHOP.BACK' | translate }}</a>
|
||||||
|
|
||||||
|
@if (product(); as p) {
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div class="image-box"></div>
|
||||||
|
|
||||||
|
<div class="info">
|
||||||
|
<span class="category">{{ p.category }}</span>
|
||||||
|
<h1>{{ p.name }}</h1>
|
||||||
|
<p class="price">{{ p.price | currency:'EUR' }}</p>
|
||||||
|
|
||||||
|
<p class="desc">{{ p.description }}</p>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<app-button variant="primary" (click)="addToCart()">
|
||||||
|
{{ 'SHOP.ADD_CART' | translate }}
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<p>Prodotto non trovato.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
20
frontend/src/app/features/shop/product-detail.component.scss
Normal file
20
frontend/src/app/features/shop/product-detail.component.scss
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
.wrapper { padding-top: var(--space-8); }
|
||||||
|
.back-link { display: inline-block; margin-bottom: var(--space-6); color: var(--color-text-muted); }
|
||||||
|
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-8);
|
||||||
|
@media(min-width: 768px) {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-box {
|
||||||
|
background-color: var(--color-neutral-200);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
aspect-ratio: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category { color: var(--color-brand); font-weight: 600; text-transform: uppercase; font-size: 0.875rem; }
|
||||||
|
.price { font-size: 1.5rem; font-weight: 700; color: var(--color-text); margin: var(--space-4) 0; }
|
||||||
|
.desc { color: var(--color-text-muted); line-height: 1.6; margin-bottom: var(--space-8); }
|
||||||
@@ -9,55 +9,8 @@ import { AppButtonComponent } from '../../shared/components/app-button/app-butto
|
|||||||
selector: 'app-product-detail',
|
selector: 'app-product-detail',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterLink, TranslateModule, AppButtonComponent],
|
imports: [CommonModule, RouterLink, TranslateModule, AppButtonComponent],
|
||||||
template: `
|
templateUrl: './product-detail.component.html',
|
||||||
<div class="container wrapper">
|
styleUrl: './product-detail.component.scss'
|
||||||
<a routerLink="/shop" class="back-link">← {{ 'SHOP.BACK' | translate }}</a>
|
|
||||||
|
|
||||||
@if (product(); as p) {
|
|
||||||
<div class="detail-grid">
|
|
||||||
<div class="image-box"></div>
|
|
||||||
|
|
||||||
<div class="info">
|
|
||||||
<span class="category">{{ p.category }}</span>
|
|
||||||
<h1>{{ p.name }}</h1>
|
|
||||||
<p class="price">{{ p.price | currency:'EUR' }}</p>
|
|
||||||
|
|
||||||
<p class="desc">{{ p.description }}</p>
|
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
<app-button variant="primary" (click)="addToCart()">
|
|
||||||
{{ 'SHOP.ADD_CART' | translate }}
|
|
||||||
</app-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<p>Prodotto non trovato.</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
styles: [`
|
|
||||||
.wrapper { padding-top: var(--space-8); }
|
|
||||||
.back-link { display: inline-block; margin-bottom: var(--space-6); color: var(--color-text-muted); }
|
|
||||||
|
|
||||||
.detail-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-8);
|
|
||||||
@media(min-width: 768px) {
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-box {
|
|
||||||
background-color: var(--color-neutral-200);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
aspect-ratio: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category { color: var(--color-brand); font-weight: 600; text-transform: uppercase; font-size: 0.875rem; }
|
|
||||||
.price { font-size: 1.5rem; font-weight: 700; color: var(--color-text); margin: var(--space-4) 0; }
|
|
||||||
.desc { color: var(--color-text-muted); line-height: 1.6; margin-bottom: var(--space-8); }
|
|
||||||
`]
|
|
||||||
})
|
})
|
||||||
export class ProductDetailComponent {
|
export class ProductDetailComponent {
|
||||||
// Input binding from router
|
// Input binding from router
|
||||||
|
|||||||
12
frontend/src/app/features/shop/shop-page.component.html
Normal file
12
frontend/src/app/features/shop/shop-page.component.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<div class="container hero">
|
||||||
|
<h1>{{ 'SHOP.TITLE' | translate }}</h1>
|
||||||
|
<p class="subtitle">{{ 'SHOP.SUBTITLE' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="grid">
|
||||||
|
@for (product of products(); track product.id) {
|
||||||
|
<app-product-card [product]="product"></app-product-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
7
frontend/src/app/features/shop/shop-page.component.scss
Normal file
7
frontend/src/app/features/shop/shop-page.component.scss
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.hero { padding: var(--space-8) 0; text-align: center; }
|
||||||
|
.subtitle { color: var(--color-text-muted); margin-bottom: var(--space-8); }
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: var(--space-6);
|
||||||
|
}
|
||||||
@@ -8,29 +8,8 @@ import { ProductCardComponent } from './components/product-card/product-card.com
|
|||||||
selector: 'app-shop-page',
|
selector: 'app-shop-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, TranslateModule, ProductCardComponent],
|
imports: [CommonModule, TranslateModule, ProductCardComponent],
|
||||||
template: `
|
templateUrl: './shop-page.component.html',
|
||||||
<div class="container hero">
|
styleUrl: './shop-page.component.scss'
|
||||||
<h1>{{ 'SHOP.TITLE' | translate }}</h1>
|
|
||||||
<p class="subtitle">{{ 'SHOP.SUBTITLE' | translate }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<div class="grid">
|
|
||||||
@for (product of products(); track product.id) {
|
|
||||||
<app-product-card [product]="product"></app-product-card>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
styles: [`
|
|
||||||
.hero { padding: var(--space-8) 0; text-align: center; }
|
|
||||||
.subtitle { color: var(--color-text-muted); margin-bottom: var(--space-8); }
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
||||||
gap: var(--space-6);
|
|
||||||
}
|
|
||||||
`]
|
|
||||||
})
|
})
|
||||||
export class ShopPageComponent {
|
export class ShopPageComponent {
|
||||||
products = signal<Product[]>([]);
|
products = signal<Product[]>([]);
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<div class="alert" [ngClass]="type()">
|
||||||
|
<div class="icon">
|
||||||
|
@if(type() === 'info') { ℹ️ }
|
||||||
|
@if(type() === 'warning') { ⚠️ }
|
||||||
|
@if(type() === 'error') { ❌ }
|
||||||
|
@if(type() === 'success') { ✅ }
|
||||||
|
</div>
|
||||||
|
<div class="content"><ng-content></ng-content></div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
.alert {
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
.info { background: var(--color-neutral-100); color: var(--color-neutral-800); }
|
||||||
|
.warning { background: #fefce8; color: #854d0e; border: 1px solid #fde047; }
|
||||||
|
.error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
|
||||||
|
.success { background: #f0fdf4; color: #166534; border: 1px solid #bbf7d0; }
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user