Compare commits
30 Commits
78af87ac3c
...
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 | |||
| 53e141f8ad |
@@ -21,6 +21,16 @@ jobs:
|
||||
java-version: '21'
|
||||
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
|
||||
run: |
|
||||
cd backend
|
||||
@@ -81,6 +91,9 @@ jobs:
|
||||
needs: build-and-push
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set ENV
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -92,7 +105,7 @@ jobs:
|
||||
echo "ENV=dev" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Trigger deploy on Unraid (forced command key)
|
||||
- name: Setup SSH key
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -120,9 +133,48 @@ jobs:
|
||||
# 5) Validazione: se fallisce qui, la chiave NON è valida/corrotta
|
||||
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
|
||||
|
||||
- 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,
|
||||
# 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 }}"
|
||||
|
||||
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 \
|
||||
libgtk-3-0 \
|
||||
libdbus-1-3 \
|
||||
libwebkit2gtk-4.1-0 \
|
||||
libwebkit2gtk-4.0-37 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install OrcaSlicer
|
||||
@@ -41,4 +41,6 @@ COPY profiles ./profiles
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["java", "-jar", "app.jar"]
|
||||
COPY entrypoint.sh .
|
||||
RUN chmod +x entrypoint.sh
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
id 'application'
|
||||
id 'org.springframework.boot' version '3.4.1'
|
||||
id 'io.spring.dependency-management' version '1.1.7'
|
||||
}
|
||||
@@ -13,12 +14,18 @@ java {
|
||||
}
|
||||
}
|
||||
|
||||
application {
|
||||
mainClass = 'com.printcalculator.BackendApplication'
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
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'
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
@@ -27,3 +34,11 @@ dependencies {
|
||||
tasks.named('test') {
|
||||
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;
|
||||
|
||||
import com.printcalculator.entity.PrinterMachine;
|
||||
import com.printcalculator.model.PrintStats;
|
||||
import com.printcalculator.model.QuoteResult;
|
||||
import com.printcalculator.repository.PrinterMachineRepository;
|
||||
import com.printcalculator.service.QuoteCalculator;
|
||||
import com.printcalculator.service.SlicerService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.HashMap;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
@RestController
|
||||
@CrossOrigin(origins = "*") // Allow all for development
|
||||
public class QuoteController {
|
||||
|
||||
private final SlicerService slicerService;
|
||||
private final QuoteCalculator quoteCalculator;
|
||||
private final PrinterMachineRepository machineRepo;
|
||||
|
||||
// Defaults
|
||||
private static final String DEFAULT_MACHINE = "Bambu_Lab_A1_machine";
|
||||
private static final String DEFAULT_FILAMENT = "Bambu_PLA_Basic";
|
||||
private static final String DEFAULT_PROCESS = "Bambu_Process_0.20_Standard";
|
||||
// Defaults (using aliases defined in ProfileManager)
|
||||
private static final String DEFAULT_FILAMENT = "pla_basic";
|
||||
private static final String DEFAULT_PROCESS = "standard";
|
||||
|
||||
public QuoteController(SlicerService slicerService, QuoteCalculator quoteCalculator) {
|
||||
public QuoteController(SlicerService slicerService, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo) {
|
||||
this.slicerService = slicerService;
|
||||
this.quoteCalculator = quoteCalculator;
|
||||
this.machineRepo = machineRepo;
|
||||
}
|
||||
|
||||
@PostMapping("/api/quote")
|
||||
public ResponseEntity<QuoteResult> calculateQuote(
|
||||
@RequestParam("file") MultipartFile file,
|
||||
@RequestParam(value = "machine", defaultValue = DEFAULT_MACHINE) String machine,
|
||||
@RequestParam(value = "filament", defaultValue = DEFAULT_FILAMENT) String filament,
|
||||
@RequestParam(value = "process", defaultValue = DEFAULT_PROCESS) String process
|
||||
@RequestParam(value = "filament", required = false, defaultValue = DEFAULT_FILAMENT) String filament,
|
||||
@RequestParam(value = "process", required = false) 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 {
|
||||
|
||||
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")
|
||||
@@ -45,30 +89,37 @@ public class QuoteController {
|
||||
@RequestParam("file") MultipartFile file
|
||||
) throws IOException {
|
||||
// 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()) {
|
||||
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
|
||||
Path tempInput = Files.createTempFile("upload_", "_" + file.getOriginalFilename());
|
||||
try {
|
||||
file.transferTo(tempInput.toFile());
|
||||
|
||||
// Slice
|
||||
PrintStats stats = slicerService.slice(tempInput.toFile(), machine, filament, process);
|
||||
String slicerMachineProfile = "bambu_a1"; // TODO: Add to PrinterMachine entity
|
||||
|
||||
PrintStats stats = slicerService.slice(tempInput.toFile(), slicerMachineProfile, filament, process, machineOverrides, processOverrides);
|
||||
|
||||
// Calculate Quote
|
||||
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);
|
||||
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
return ResponseEntity.internalServerError().build(); // Simplify error handling for now
|
||||
return ResponseEntity.internalServerError().build();
|
||||
} finally {
|
||||
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;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
public class QuoteResult {
|
||||
private double totalPrice;
|
||||
private String currency;
|
||||
private PrintStats stats;
|
||||
private double setupCost;
|
||||
|
||||
public record QuoteResult(
|
||||
BigDecimal totalPrice,
|
||||
String currency,
|
||||
PrintStats stats,
|
||||
CostBreakdown breakdown,
|
||||
List<String> notes
|
||||
) {}
|
||||
public QuoteResult(double totalPrice, String currency, PrintStats stats, double setupCost) {
|
||||
this.totalPrice = totalPrice;
|
||||
this.currency = currency;
|
||||
this.stats = stats;
|
||||
this.setupCost = setupCost;
|
||||
}
|
||||
|
||||
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
|
||||
public class GCodeParser {
|
||||
|
||||
private static final Pattern TIME_PATTERN = Pattern.compile("estimated printing time = (.*)");
|
||||
private static final Pattern FILAMENT_G_PATTERN = Pattern.compile("filament used \\[g\\] = (.*)");
|
||||
private static final Pattern FILAMENT_MM_PATTERN = Pattern.compile("filament used \\[mm\\] = (.*)");
|
||||
// OrcaSlicer/BambuStudio format
|
||||
// ; estimated printing time = 1h 2m 3s
|
||||
// ; 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 {
|
||||
long seconds = 0;
|
||||
@@ -25,12 +37,33 @@ public class GCodeParser {
|
||||
|
||||
try (BufferedReader reader = new BufferedReader(new FileReader(gcodeFile))) {
|
||||
String line;
|
||||
// Scan first 500 lines for efficiency
|
||||
int count = 0;
|
||||
while ((line = reader.readLine()) != null && count < 500) {
|
||||
|
||||
// Scan entire file as metadata is often at the end
|
||||
while ((line = reader.readLine()) != null) {
|
||||
line = line.trim();
|
||||
|
||||
// OrcaSlicer comments start with ;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -38,12 +71,14 @@ public class GCodeParser {
|
||||
if (timeMatcher.find()) {
|
||||
timeFormatted = timeMatcher.group(1).trim();
|
||||
seconds = parseTimeString(timeFormatted);
|
||||
System.out.println("GCodeParser: Found time: " + timeFormatted + " (" + seconds + "s)");
|
||||
}
|
||||
|
||||
Matcher weightMatcher = FILAMENT_G_PATTERN.matcher(line);
|
||||
if (weightMatcher.find()) {
|
||||
try {
|
||||
weightG = Double.parseDouble(weightMatcher.group(1).trim());
|
||||
System.out.println("GCodeParser: Found weight: " + weightG + "g");
|
||||
} catch (NumberFormatException ignored) {}
|
||||
}
|
||||
|
||||
@@ -51,9 +86,9 @@ public class GCodeParser {
|
||||
if (lengthMatcher.find()) {
|
||||
try {
|
||||
lengthMm = Double.parseDouble(lengthMatcher.group(1).trim());
|
||||
System.out.println("GCodeParser: Found length: " + lengthMm + "mm");
|
||||
} catch (NumberFormatException ignored) {}
|
||||
}
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,21 +96,60 @@ public class GCodeParser {
|
||||
}
|
||||
|
||||
private long parseTimeString(String timeStr) {
|
||||
// Formats: "1d 2h 3m 4s" or "1h 20m 10s"
|
||||
long totalSeconds = 0;
|
||||
|
||||
Matcher d = Pattern.compile("(\\d+)d").matcher(timeStr);
|
||||
if (d.find()) totalSeconds += Long.parseLong(d.group(1)) * 86400;
|
||||
// Formats: "1d 2h 3m 4s", "1h 20m 10s", "01:23:45", "12:34"
|
||||
String lower = timeStr.toLowerCase();
|
||||
double totalSeconds = 0;
|
||||
boolean matched = false;
|
||||
|
||||
Matcher h = Pattern.compile("(\\d+)h").matcher(timeStr);
|
||||
if (h.find()) totalSeconds += Long.parseLong(h.group(1)) * 3600;
|
||||
Matcher d = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*d").matcher(lower);
|
||||
if (d.find()) {
|
||||
totalSeconds += Double.parseDouble(d.group(1)) * 86400;
|
||||
matched = true;
|
||||
}
|
||||
|
||||
Matcher m = Pattern.compile("(\\d+)m").matcher(timeStr);
|
||||
if (m.find()) totalSeconds += Long.parseLong(m.group(1)) * 60;
|
||||
Matcher h = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*h").matcher(lower);
|
||||
if (h.find()) {
|
||||
totalSeconds += Double.parseDouble(h.group(1)) * 3600;
|
||||
matched = true;
|
||||
}
|
||||
|
||||
Matcher s = Pattern.compile("(\\d+)s").matcher(timeStr);
|
||||
if (s.find()) totalSeconds += Long.parseLong(s.group(1));
|
||||
Matcher m = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*m").matcher(lower);
|
||||
if (m.find()) {
|
||||
totalSeconds += Double.parseDouble(m.group(1)) * 60;
|
||||
matched = true;
|
||||
}
|
||||
|
||||
return totalSeconds;
|
||||
Matcher s = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*s").matcher(lower);
|
||||
if (s.find()) {
|
||||
totalSeconds += Double.parseDouble(s.group(1));
|
||||
matched = true;
|
||||
}
|
||||
|
||||
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.logging.Logger;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.Map;
|
||||
import java.util.HashMap;
|
||||
|
||||
@Service
|
||||
public class ProfileManager {
|
||||
@@ -22,9 +24,31 @@ public class ProfileManager {
|
||||
private final String profilesRoot;
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
private final Map<String, String> profileAliases;
|
||||
|
||||
public ProfileManager(@Value("${profiles.root:profiles}") String profilesRoot, ObjectMapper mapper) {
|
||||
this.profilesRoot = profilesRoot;
|
||||
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 {
|
||||
@@ -36,9 +60,12 @@ public class ProfileManager {
|
||||
}
|
||||
|
||||
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
|
||||
// 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))) {
|
||||
Optional<Path> found = stream
|
||||
|
||||
@@ -1,59 +1,158 @@
|
||||
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.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 java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
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) {
|
||||
this.props = props;
|
||||
public QuoteCalculator(PricingPolicyRepository pricingRepo,
|
||||
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
|
||||
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
|
||||
BigDecimal hours = BigDecimal.valueOf(stats.printTimeSeconds()).divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP);
|
||||
BigDecimal machineCost = hours.multiply(BigDecimal.valueOf(props.getMachineCostPerHour()));
|
||||
// Machine Cost: Tiered
|
||||
BigDecimal totalHours = BigDecimal.valueOf(stats.printTimeSeconds()).divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP);
|
||||
BigDecimal machineCost = calculateMachineCost(policy, totalHours);
|
||||
|
||||
// Energy Cost: (watts / 1000) * hours * costPerKwh
|
||||
BigDecimal kw = BigDecimal.valueOf(props.getPrinterPowerWatts()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
|
||||
BigDecimal kwh = kw.multiply(hours);
|
||||
BigDecimal energyCost = kwh.multiply(BigDecimal.valueOf(props.getEnergyCostPerKwh()));
|
||||
BigDecimal kw = BigDecimal.valueOf(machine.getPowerWatts()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
|
||||
BigDecimal kwh = kw.multiply(totalHours);
|
||||
BigDecimal energyCost = kwh.multiply(policy.getElectricityCostChfPerKwh());
|
||||
|
||||
// Subtotal
|
||||
BigDecimal subtotal = materialCost.add(machineCost).add(energyCost);
|
||||
// Subtotal (Costs + Fixed Fees)
|
||||
BigDecimal fixedFee = policy.getFixedJobFeeChf();
|
||||
BigDecimal subtotal = materialCost.add(machineCost).add(energyCost).add(fixedFee);
|
||||
|
||||
// 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 markupAmount = totalPrice.subtract(subtotal);
|
||||
return new QuoteResult(totalPrice.doubleValue(), "CHF", stats, fixedFee.doubleValue());
|
||||
}
|
||||
|
||||
CostBreakdown breakdown = new CostBreakdown(
|
||||
materialCost.setScale(2, RoundingMode.HALF_UP),
|
||||
machineCost.setScale(2, RoundingMode.HALF_UP),
|
||||
energyCost.setScale(2, RoundingMode.HALF_UP),
|
||||
subtotal.setScale(2, RoundingMode.HALF_UP),
|
||||
markupAmount.setScale(2, RoundingMode.HALF_UP)
|
||||
);
|
||||
private BigDecimal calculateMachineCost(PricingPolicy policy, BigDecimal hours) {
|
||||
List<PricingPolicyMachineHourTier> tiers = tierRepo.findAllByPricingPolicyOrderByTierStartHoursAsc(policy);
|
||||
if (tiers.isEmpty()) {
|
||||
return BigDecimal.ZERO; // Should not happen if DB is correct
|
||||
}
|
||||
|
||||
BigDecimal remainingHours = hours;
|
||||
BigDecimal totalCost = BigDecimal.ZERO;
|
||||
BigDecimal processedHours = BigDecimal.ZERO;
|
||||
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
List<String> notes = new ArrayList<>();
|
||||
notes.add("Generated via Dynamic Slicer (Java Backend)");
|
||||
return totalCost;
|
||||
}
|
||||
|
||||
return new QuoteResult(totalPrice, "EUR", stats, breakdown, notes);
|
||||
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.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
@@ -36,12 +37,21 @@ public class SlicerService {
|
||||
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
|
||||
ObjectNode machineProfile = profileManager.getMergedProfile(machineName, "machine");
|
||||
ObjectNode filamentProfile = profileManager.getMergedProfile(filamentName, "filament");
|
||||
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
|
||||
Path tempDir = Files.createTempDirectory("slicer_job_");
|
||||
try {
|
||||
@@ -55,12 +65,16 @@ public class SlicerService {
|
||||
|
||||
// 3. Build Command
|
||||
// --load-settings "machine.json;process.json" --load-filaments "filament.json"
|
||||
String settingsArg = mFile.getAbsolutePath() + ";" + pFile.getAbsolutePath();
|
||||
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(slicerPath);
|
||||
|
||||
// Load machine 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(fFile.getAbsolutePath());
|
||||
command.add("--ensure-on-bed");
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
spring.application.name=backend
|
||||
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
|
||||
# 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}
|
||||
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
|
||||
spring.servlet.multipart.max-file-size=200MB
|
||||
|
||||
@@ -52,6 +52,62 @@ class GCodeParserTest {
|
||||
assertEquals(750, stats.printTimeSeconds()); // 12*60 + 30
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
REPO_OWNER=JoeKung
|
||||
REPO_OWNER=joekung
|
||||
ENV=dev
|
||||
TAG=dev
|
||||
|
||||
@@ -7,9 +7,4 @@ TAG=dev
|
||||
BACKEND_PORT=18002
|
||||
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
|
||||
REPO_OWNER=JoeKung
|
||||
REPO_OWNER=joekung
|
||||
ENV=int
|
||||
TAG=int
|
||||
|
||||
@@ -7,9 +7,4 @@ TAG=int
|
||||
BACKEND_PORT=18001
|
||||
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
|
||||
REPO_OWNER=JoeKung
|
||||
REPO_OWNER=joekung
|
||||
ENV=prod
|
||||
TAG=prod
|
||||
|
||||
@@ -7,9 +7,4 @@ TAG=prod
|
||||
BACKEND_PORT=8000
|
||||
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,28 +7,29 @@ services:
|
||||
container_name: print-calculator-backend-${ENV}
|
||||
ports:
|
||||
- "${BACKEND_PORT}:8000"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- FILAMENT_COST_PER_KG=${FILAMENT_COST_PER_KG}
|
||||
- MACHINE_COST_PER_HOUR=${MACHINE_COST_PER_HOUR}
|
||||
- ENERGY_COST_PER_KWH=${ENERGY_COST_PER_KWH}
|
||||
- PRINTER_POWER_WATTS=${PRINTER_POWER_WATTS}
|
||||
- MARKUP_PERCENT=${MARKUP_PERCENT}
|
||||
- DB_URL=${DB_URL}
|
||||
- DB_USERNAME=${DB_USERNAME}
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
- TEMP_DIR=/app/temp
|
||||
- PROFILES_DIR=/app/profiles
|
||||
restart: unless-stopped
|
||||
restart: always
|
||||
volumes:
|
||||
- backend_profiles_${ENV}:/app/profiles
|
||||
|
||||
|
||||
frontend:
|
||||
image: ${REGISTRY_URL}/${REPO_OWNER}/print-calculator-frontend:${TAG}
|
||||
container_name: print-calculator-frontend-${ENV}
|
||||
ports:
|
||||
- "${FRONTEND_PORT}:8008"
|
||||
- "${FRONTEND_PORT}:80"
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
restart: always
|
||||
|
||||
volumes:
|
||||
backend_profiles_prod:
|
||||
backend_profiles_int:
|
||||
backend_profiles_dev:
|
||||
backend_profiles_dev:
|
||||
|
||||
@@ -9,6 +9,10 @@ services:
|
||||
ports:
|
||||
- "8000:8000"
|
||||
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
|
||||
- MACHINE_COST_PER_HOUR=2.50
|
||||
- ENERGY_COST_PER_KWH=0.30
|
||||
@@ -16,13 +20,34 @@ services:
|
||||
- MARKUP_PERCENT=20
|
||||
- TEMP_DIR=/app/temp
|
||||
- PROFILES_DIR=/app/profiles
|
||||
depends_on:
|
||||
- db
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile.dev
|
||||
container_name: print-calculator-frontend
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
- backend
|
||||
- db
|
||||
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,
|
||||
"extractLicenses": false,
|
||||
"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": {
|
||||
"buildTarget": "frontend:build:development"
|
||||
},
|
||||
"local": {
|
||||
"buildTarget": "frontend:build:local"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
|
||||
@@ -10,7 +10,7 @@ export const routes: Routes = [
|
||||
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)
|
||||
},
|
||||
{
|
||||
@@ -24,6 +24,10 @@ export const routes: Routes = [
|
||||
{
|
||||
path: 'contact',
|
||||
loadChildren: () => import('./features/contact/contact.routes').then(m => m.CONTACT_ROUTES)
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
loadChildren: () => import('./features/legal/legal.routes').then(m => m.LEGAL_ROUTES)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
<span class="brand">3D fab</span>
|
||||
<p class="copyright">© 2026 3D fab.</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="col links">
|
||||
<a routerLink="/privacy">{{ 'FOOTER.PRIVACY' | 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 class="col social">
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
<nav class="nav-links" [class.open]="isMenuOpen">
|
||||
<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="/about" routerLinkActive="active" (click)="closeMenu()">{{ 'NAV.ABOUT' | translate }}</a>
|
||||
<a routerLink="/contact" routerLinkActive="active" (click)="closeMenu()">{{ 'NAV.CONTACT' | translate }}</a>
|
||||
|
||||
@@ -1,64 +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>
|
||||
|
||||
<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 (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="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>
|
||||
@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>
|
||||
} @else if (result()) {
|
||||
<app-quote-result
|
||||
[result]="result()!"
|
||||
(consult)="onConsult()"
|
||||
(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>
|
||||
</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>
|
||||
}
|
||||
|
||||
@@ -26,11 +26,11 @@
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Make children (specifically app-card) stretch */
|
||||
> * {
|
||||
flex: 1;
|
||||
}
|
||||
/* Stretch only the loading card so the spinner stays centered */
|
||||
.col-result > .loading-state {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Mode Selector (Segmented Control style) */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, signal, ViewChild, ElementRef } from '@angular/core';
|
||||
import { Component, signal, ViewChild, ElementRef, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
@@ -6,20 +6,21 @@ import { AppCardComponent } from '../../shared/components/app-card/app-card.comp
|
||||
import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component';
|
||||
import { UploadFormComponent } from './components/upload-form/upload-form.component';
|
||||
import { QuoteResultComponent } from './components/quote-result/quote-result.component';
|
||||
import { QuoteRequest, QuoteResult, QuoteEstimatorService } from './services/quote-estimator.service';
|
||||
import { UserDetailsComponent } from './components/user-details/user-details.component';
|
||||
import { QuoteEstimatorService, QuoteRequest, QuoteResult } from './services/quote-estimator.service';
|
||||
import { Router } from '@angular/router';
|
||||
import { SuccessStateComponent } from '../../shared/components/success-state/success-state.component';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-calculator-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, TranslateModule, AppCardComponent, AppAlertComponent, UploadFormComponent, QuoteResultComponent, UserDetailsComponent],
|
||||
imports: [CommonModule, TranslateModule, AppCardComponent, AppAlertComponent, UploadFormComponent, QuoteResultComponent, UserDetailsComponent, SuccessStateComponent],
|
||||
templateUrl: './calculator-page.component.html',
|
||||
styleUrl: './calculator-page.component.scss'
|
||||
})
|
||||
export class CalculatorPageComponent {
|
||||
export class CalculatorPageComponent implements OnInit {
|
||||
mode = signal<any>('easy');
|
||||
step = signal<'upload' | 'quote' | 'details'>('upload');
|
||||
step = signal<'upload' | 'quote' | 'details' | 'success'>('upload');
|
||||
|
||||
loading = signal(false);
|
||||
uploadProgress = signal(0);
|
||||
@@ -31,9 +32,22 @@ export class CalculatorPageComponent {
|
||||
@ViewChild('uploadForm') uploadForm!: UploadFormComponent;
|
||||
@ViewChild('resultCol') resultCol!: ElementRef;
|
||||
|
||||
constructor(private estimator: QuoteEstimatorService, private router: Router) {}
|
||||
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) {
|
||||
// ... (logic remains the same, simplified for diff)
|
||||
this.currentRequest = req;
|
||||
this.loading.set(true);
|
||||
this.uploadProgress.set(0);
|
||||
@@ -78,12 +92,14 @@ export class CalculatorPageComponent {
|
||||
onSubmitOrder(orderData: any) {
|
||||
console.log('Order Submitted:', orderData);
|
||||
this.orderSuccess.set(true);
|
||||
this.step.set('upload'); // Reset to start, or show success page?
|
||||
// For now, let's show success message and reset
|
||||
setTimeout(() => {
|
||||
this.orderSuccess.set(false);
|
||||
}, 5000);
|
||||
this.result.set(null);
|
||||
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;
|
||||
|
||||
@@ -2,5 +2,7 @@ import { Routes } from '@angular/router';
|
||||
import { CalculatorPageComponent } from './calculator-page.component';
|
||||
|
||||
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' } }
|
||||
];
|
||||
|
||||
@@ -24,6 +24,13 @@
|
||||
<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) -->
|
||||
|
||||
@@ -83,3 +83,27 @@
|
||||
}
|
||||
|
||||
.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 */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
<label>COLORE</label>
|
||||
<app-color-selector
|
||||
[selectedColor]="item.color"
|
||||
[variants]="currentMaterialVariants()"
|
||||
(colorSelected)="updateItemColor(i, $event)">
|
||||
</app-color-selector>
|
||||
</div>
|
||||
@@ -80,20 +81,20 @@
|
||||
<app-select
|
||||
formControlName="material"
|
||||
[label]="'CALC.MATERIAL' | translate"
|
||||
[options]="materials"
|
||||
[options]="materials()"
|
||||
></app-select>
|
||||
|
||||
@if (mode() === 'easy') {
|
||||
<app-select
|
||||
formControlName="quality"
|
||||
[label]="'CALC.QUALITY' | translate"
|
||||
[options]="qualities"
|
||||
[options]="qualities()"
|
||||
></app-select>
|
||||
} @else {
|
||||
<app-select
|
||||
formControlName="nozzleDiameter"
|
||||
[label]="'CALC.NOZZLE' | translate"
|
||||
[options]="nozzleDiameters"
|
||||
[options]="nozzleDiameters()"
|
||||
></app-select>
|
||||
}
|
||||
</div>
|
||||
@@ -105,13 +106,13 @@
|
||||
<app-select
|
||||
formControlName="infillPattern"
|
||||
[label]="'CALC.PATTERN' | translate"
|
||||
[options]="infillPatterns"
|
||||
[options]="infillPatterns()"
|
||||
></app-select>
|
||||
|
||||
<app-select
|
||||
formControlName="layerHeight"
|
||||
[label]="'CALC.LAYER_HEIGHT' | translate"
|
||||
[options]="layerHeights"
|
||||
[options]="layerHeights()"
|
||||
></app-select>
|
||||
</div>
|
||||
|
||||
@@ -128,13 +129,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-input
|
||||
formControlName="notes"
|
||||
[label]="'CALC.NOTES' | translate"
|
||||
placeholder="Istruzioni specifiche..."
|
||||
></app-input>
|
||||
}
|
||||
|
||||
<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) {
|
||||
@@ -147,7 +149,7 @@
|
||||
|
||||
<app-button
|
||||
type="submit"
|
||||
[disabled]="form.invalid || items().length === 0 || loading()"
|
||||
[disabled]="items().length === 0 || loading()"
|
||||
[fullWidth]="true">
|
||||
{{ loading() ? (uploadProgress() < 100 ? 'Uploading...' : 'Processing...') : ('CALC.CALCULATE' | translate) }}
|
||||
</app-button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, input, output, signal, effect } from '@angular/core';
|
||||
import { Component, input, output, signal, effect, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
@@ -8,7 +8,7 @@ import { AppDropzoneComponent } from '../../../../shared/components/app-dropzone
|
||||
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
|
||||
import { StlViewerComponent } from '../../../../shared/components/stl-viewer/stl-viewer.component';
|
||||
import { ColorSelectorComponent } from '../../../../shared/components/color-selector/color-selector.component';
|
||||
import { QuoteRequest } from '../../services/quote-estimator.service';
|
||||
import { QuoteRequest, QuoteEstimatorService, OptionsResponse, SimpleOption, MaterialOption, VariantOption } from '../../services/quote-estimator.service';
|
||||
import { getColorHex } from '../../../../core/constants/colors.const';
|
||||
|
||||
interface FormItem {
|
||||
@@ -24,69 +24,110 @@ interface FormItem {
|
||||
templateUrl: './upload-form.component.html',
|
||||
styleUrl: './upload-form.component.scss'
|
||||
})
|
||||
export class UploadFormComponent {
|
||||
export class UploadFormComponent implements OnInit {
|
||||
mode = input<'easy' | 'advanced'>('easy');
|
||||
loading = input<boolean>(false);
|
||||
uploadProgress = input<number>(0);
|
||||
submitRequest = output<QuoteRequest>();
|
||||
|
||||
private estimator = inject(QuoteEstimatorService);
|
||||
private fb = inject(FormBuilder);
|
||||
|
||||
form: FormGroup;
|
||||
|
||||
items = signal<FormItem[]>([]);
|
||||
selectedFile = signal<File | null>(null);
|
||||
|
||||
materials = [
|
||||
{ label: 'PLA (Standard)', value: 'PLA' },
|
||||
{ label: 'PETG (Resistente)', value: 'PETG' },
|
||||
{ label: 'TPU (Flessibile)', value: 'TPU' }
|
||||
];
|
||||
|
||||
qualities = [
|
||||
{ label: 'Bozza (Fast)', value: 'Draft' },
|
||||
{ label: 'Standard', value: 'Standard' },
|
||||
{ label: 'Alta definizione', value: 'High' }
|
||||
];
|
||||
|
||||
nozzleDiameters = [
|
||||
{ label: '0.2 mm (+2 CHF)', value: 0.2 },
|
||||
{ label: '0.4 mm (Standard)', value: 0.4 },
|
||||
{ label: '0.6 mm (+2 CHF)', value: 0.6 },
|
||||
{ label: '0.8 mm (+2 CHF)', value: 0.8 }
|
||||
];
|
||||
// Dynamic Options
|
||||
materials = signal<SimpleOption[]>([]);
|
||||
qualities = signal<SimpleOption[]>([]);
|
||||
nozzleDiameters = signal<SimpleOption[]>([]);
|
||||
infillPatterns = signal<SimpleOption[]>([]);
|
||||
layerHeights = signal<SimpleOption[]>([]);
|
||||
|
||||
infillPatterns = [
|
||||
{ label: 'Grid', value: 'grid' },
|
||||
{ label: 'Gyroid', value: 'gyroid' },
|
||||
{ label: 'Cubic', value: 'cubic' },
|
||||
{ label: 'Triangles', value: 'triangles' }
|
||||
];
|
||||
|
||||
layerHeights = [
|
||||
{ label: '0.08 mm', value: 0.08 },
|
||||
{ label: '0.12 mm (High Quality - Slow)', value: 0.12 },
|
||||
{ label: '0.16 mm', value: 0.16 },
|
||||
{ label: '0.20 mm (Standard)', value: 0.20 },
|
||||
{ label: '0.24 mm', value: 0.24 },
|
||||
{ label: '0.28 mm', value: 0.28 }
|
||||
];
|
||||
// Store full material options to lookup variants/colors if needed later
|
||||
private fullMaterialOptions: MaterialOption[] = [];
|
||||
|
||||
// Computed variants for valid material
|
||||
currentMaterialVariants = signal<VariantOption[]>([]);
|
||||
|
||||
private updateVariants() {
|
||||
const matCode = this.form.get('material')?.value;
|
||||
if (matCode && this.fullMaterialOptions.length > 0) {
|
||||
const found = this.fullMaterialOptions.find(m => m.code === matCode);
|
||||
this.currentMaterialVariants.set(found ? found.variants : []);
|
||||
} else {
|
||||
this.currentMaterialVariants.set([]);
|
||||
}
|
||||
}
|
||||
|
||||
acceptedFormats = '.stl,.3mf,.step,.stp,.obj,.amf,.ply,.igs,.iges';
|
||||
|
||||
constructor(private fb: FormBuilder) {
|
||||
constructor() {
|
||||
this.form = this.fb.group({
|
||||
itemsTouched: [false], // Hack to track touched state for custom items list
|
||||
material: ['PLA', Validators.required],
|
||||
quality: ['Standard', Validators.required],
|
||||
// Print Speed removed
|
||||
material: ['', Validators.required],
|
||||
quality: ['', Validators.required],
|
||||
items: [[]], // Track items in form for validation if needed
|
||||
notes: [''],
|
||||
// Advanced fields
|
||||
// Color removed from global form
|
||||
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'],
|
||||
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[]) {
|
||||
@@ -150,6 +191,11 @@ export class UploadFormComponent {
|
||||
|
||||
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';
|
||||
@@ -187,13 +233,25 @@ export class UploadFormComponent {
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
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({
|
||||
items: this.items(), // Pass the items array including colors
|
||||
...this.form.value,
|
||||
items: this.items(), // Pass the items array explicitly AFTER form value to prevent overwrite
|
||||
mode: this.mode()
|
||||
});
|
||||
} 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.get('itemsTouched')?.setValue(true);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
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>
|
||||
@@ -19,6 +20,7 @@
|
||||
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>
|
||||
@@ -32,6 +34,7 @@
|
||||
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>
|
||||
@@ -41,6 +44,7 @@
|
||||
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>
|
||||
@@ -51,6 +55,7 @@
|
||||
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>
|
||||
|
||||
@@ -61,6 +66,7 @@
|
||||
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>
|
||||
@@ -69,6 +75,7 @@
|
||||
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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, forkJoin, of } from 'rxjs';
|
||||
import { map, catchError } from 'rxjs/operators';
|
||||
import { HttpClient, HttpEventType } from '@angular/common/http';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { map, catchError, tap } from 'rxjs/operators';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
|
||||
export interface QuoteRequest {
|
||||
@@ -9,7 +9,6 @@ export interface QuoteRequest {
|
||||
material: string;
|
||||
quality: string;
|
||||
notes?: string;
|
||||
// color removed from global scope
|
||||
infillDensity?: number;
|
||||
infillPattern?: string;
|
||||
supportEnabled?: boolean;
|
||||
@@ -26,36 +25,231 @@ export interface QuoteItem {
|
||||
quantity: number;
|
||||
material?: string;
|
||||
color?: string;
|
||||
// Computed values for UI convenience (optional, can be done in component)
|
||||
}
|
||||
|
||||
export interface QuoteResult {
|
||||
items: QuoteItem[];
|
||||
setupCost: number;
|
||||
currency: string;
|
||||
// The following are aggregations that can be re-calculated
|
||||
totalPrice: number;
|
||||
totalTimeHours: number;
|
||||
totalTimeMinutes: number;
|
||||
totalWeight: number;
|
||||
notes?: string;
|
||||
}
|
||||
// ... (skip down to calculate logic)
|
||||
|
||||
interface BackendResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
print_time_seconds: number;
|
||||
material_grams: number;
|
||||
cost: {
|
||||
total: number;
|
||||
};
|
||||
};
|
||||
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({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class QuoteEstimatorService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
getOptions(): Observable<OptionsResponse> {
|
||||
console.log('QuoteEstimatorService: Requesting options...');
|
||||
const headers: any = {};
|
||||
// @ts-ignore
|
||||
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
|
||||
return this.http.get<OptionsResponse>(`${environment.apiUrl}/api/calculator/options`, { headers }).pipe(
|
||||
tap({
|
||||
next: (res) => console.log('QuoteEstimatorService: Options loaded', res),
|
||||
error: (err) => console.error('QuoteEstimatorService: Options failed', err)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
calculate(request: QuoteRequest): Observable<number | QuoteResult> {
|
||||
console.log('QuoteEstimatorService: Calculating quote...', request);
|
||||
if (request.items.length === 0) {
|
||||
console.warn('QuoteEstimatorService: No items to calculate');
|
||||
return of();
|
||||
}
|
||||
|
||||
return new Observable(observer => {
|
||||
const totalItems = request.items.length;
|
||||
const allProgress: number[] = new Array(totalItems).fill(0);
|
||||
const finalResponses: any[] = [];
|
||||
let completedRequests = 0;
|
||||
|
||||
const uploads = request.items.map((item, index) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', item.file);
|
||||
// machine param removed - backend uses default active
|
||||
|
||||
// Map material? Or trust frontend to send correct code?
|
||||
// Since we fetch options now, we should send the code directly.
|
||||
// But for backward compat/safety/mapping logic in mapMaterial, let's keep it or update it.
|
||||
// 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 }))
|
||||
);
|
||||
});
|
||||
|
||||
// Subscribe to all
|
||||
uploads.forEach((obs) => {
|
||||
obs.subscribe({
|
||||
next: (wrapper: any) => {
|
||||
const idx = wrapper.index;
|
||||
|
||||
if (wrapper.error) {
|
||||
finalResponses[idx] = { success: false, fileName: wrapper.item.file.name };
|
||||
}
|
||||
|
||||
const event = wrapper.event;
|
||||
if (event && event.type === HttpEventType.UploadProgress) {
|
||||
if (event.total) {
|
||||
const percent = Math.round((100 * event.loaded) / event.total);
|
||||
allProgress[idx] = percent;
|
||||
// Emit average progress
|
||||
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 && res.success) {
|
||||
// Find original item to get color
|
||||
const originalItem = request.items[idx];
|
||||
// Note: responses and request.items are index-aligned because we mapped them
|
||||
|
||||
items.push({
|
||||
fileName: res.fileName,
|
||||
unitPrice: res.data.cost.total,
|
||||
unitTime: res.data.print_time_seconds,
|
||||
unitWeight: res.data.material_grams,
|
||||
quantity: res.originalQty,
|
||||
material: request.material,
|
||||
color: originalItem.color || 'Default'
|
||||
});
|
||||
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) {
|
||||
@@ -64,7 +258,8 @@ export interface QuoteResult {
|
||||
}
|
||||
|
||||
// Initial Aggregation
|
||||
let grandTotal = setupCost;
|
||||
const useBackendSetup = setupCostFromBackend != null;
|
||||
let grandTotal = useBackendSetup ? 0 : setupCost;
|
||||
let totalTime = 0;
|
||||
let totalWeight = 0;
|
||||
|
||||
@@ -79,12 +274,13 @@ export interface QuoteResult {
|
||||
|
||||
const result: QuoteResult = {
|
||||
items,
|
||||
setupCost,
|
||||
currency: 'CHF',
|
||||
setupCost: useBackendSetup ? setupCostFromBackend! : setupCost,
|
||||
currency: currencyFromBackend || 'CHF',
|
||||
totalPrice: Math.round(grandTotal * 100) / 100,
|
||||
totalTimeHours: totalHours,
|
||||
totalTimeMinutes: totalMinutes,
|
||||
totalWeight: Math.ceil(totalWeight)
|
||||
totalWeight: Math.ceil(totalWeight),
|
||||
notes: request.notes
|
||||
};
|
||||
|
||||
observer.next(result);
|
||||
@@ -93,9 +289,9 @@ export interface QuoteResult {
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error in request', err);
|
||||
console.error('Error in request subscription', err);
|
||||
completedRequests++;
|
||||
if (completedRequests === totalItems) {
|
||||
if (completedRequests === totalItems) {
|
||||
observer.error('Requests failed');
|
||||
}
|
||||
}
|
||||
@@ -104,6 +300,31 @@ export interface QuoteResult {
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
const m = mat.toUpperCase();
|
||||
if (m.includes('PLA')) return 'pla_basic';
|
||||
|
||||
@@ -1,73 +1,77 @@
|
||||
<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>
|
||||
@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="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 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>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<app-button type="submit" [disabled]="form.invalid || sent()">
|
||||
{{ sent() ? ('CONTACT.MSG_SENT' | translate) : ('CONTACT.SEND' | translate) }}
|
||||
</app-button>
|
||||
</div>
|
||||
</form>
|
||||
<!-- 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>
|
||||
}
|
||||
|
||||
@@ -131,3 +131,5 @@ app-input.col { width: 100%; }
|
||||
display: flex; align-items: center; justify-content: center; line-height: 1;
|
||||
&:hover { background: red; }
|
||||
}
|
||||
|
||||
/* Success State styles moved to shared component */
|
||||
|
||||
@@ -12,10 +12,12 @@ interface FilePreview {
|
||||
type: 'image' | 'pdf' | '3d' | 'other';
|
||||
}
|
||||
|
||||
import { SuccessStateComponent } from '../../../../shared/components/success-state/success-state.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-contact-form',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppButtonComponent],
|
||||
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppButtonComponent, SuccessStateComponent],
|
||||
templateUrl: './contact-form.component.html',
|
||||
styleUrl: './contact-form.component.scss'
|
||||
})
|
||||
@@ -161,13 +163,14 @@ export class ContactFormComponent {
|
||||
console.log('Form Submit:', formData);
|
||||
|
||||
this.sent.set(true);
|
||||
setTimeout(() => {
|
||||
this.sent.set(false);
|
||||
this.form.reset({ requestType: 'custom', isCompany: false });
|
||||
this.files.set([]);
|
||||
}, 3000);
|
||||
} else {
|
||||
this.form.markAllAsTouched();
|
||||
}
|
||||
}
|
||||
|
||||
resetForm() {
|
||||
this.sent.set(false);
|
||||
this.form.reset({ requestType: 'custom', isCompany: false });
|
||||
this.files.set([]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
Se devi ancora crearlo, il nostro team di design lo progetterà per te.
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<app-button variant="primary" routerLink="/cal">Calcola Preventivo</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="text" routerLink="/contact">Parla con noi</app-button>
|
||||
</div>
|
||||
@@ -49,7 +49,7 @@
|
||||
<li>Ricevi subito costo e tempo</li>
|
||||
</ul>
|
||||
<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="/contact">Parla con noi</app-button>
|
||||
</div>
|
||||
</app-card>
|
||||
|
||||
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 {}
|
||||
@@ -1,5 +1,10 @@
|
||||
<div class="form-group">
|
||||
@if (label()) { <label [for]="id()">{{ label() }}</label> }
|
||||
@if (label()) {
|
||||
<label [for]="id()">
|
||||
{{ label() }}
|
||||
@if (required()) { <span class="required-mark">*</span> }
|
||||
</label>
|
||||
}
|
||||
<input
|
||||
[id]="id()"
|
||||
[type]="type()"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
.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); }
|
||||
.required-mark { color: var(--color-danger-500); margin-left: 2px; }
|
||||
.form-control {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
|
||||
@@ -22,6 +22,7 @@ export class AppInputComponent implements ControlValueAccessor {
|
||||
type = input<string>('text');
|
||||
placeholder = input<string>('');
|
||||
error = input<string | null>(null);
|
||||
required = input<boolean>(false);
|
||||
|
||||
value: string = '';
|
||||
disabled = false;
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
@if (isOpen()) {
|
||||
<div class="color-popup">
|
||||
@for (category of categories; track category.name) {
|
||||
@for (category of categories(); track category.name) {
|
||||
<div class="category">
|
||||
<div class="category-name">{{ category.name }}</div>
|
||||
<div class="colors-grid">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
import { Component, input, output, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { PRODUCT_COLORS, getColorHex, ColorCategory, ColorOption } from '../../../core/constants/colors.const';
|
||||
import { VariantOption } from '../../../features/calculator/services/quote-estimator.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-color-selector',
|
||||
@@ -12,11 +13,28 @@ import { PRODUCT_COLORS, getColorHex, ColorCategory, ColorOption } from '../../.
|
||||
})
|
||||
export class ColorSelectorComponent {
|
||||
selectedColor = input<string>('Black');
|
||||
variants = input<VariantOption[]>([]);
|
||||
colorSelected = output<string>();
|
||||
|
||||
isOpen = signal(false);
|
||||
|
||||
categories: ColorCategory[] = PRODUCT_COLORS;
|
||||
categories = computed(() => {
|
||||
const vars = this.variants();
|
||||
if (vars && vars.length > 0) {
|
||||
// Flatten variants into a single category for now
|
||||
// We could try to group by extracting words, but "Colors" is fine.
|
||||
return [{
|
||||
name: 'Available Colors',
|
||||
colors: vars.map(v => ({
|
||||
label: v.colorName, // Display "Red"
|
||||
value: v.colorName, // Send "Red" to backend
|
||||
hex: v.hexColor,
|
||||
outOfStock: v.isOutOfStock
|
||||
}))
|
||||
}] as ColorCategory[];
|
||||
}
|
||||
return PRODUCT_COLORS;
|
||||
});
|
||||
|
||||
toggleOpen() {
|
||||
this.isOpen.update(v => !v);
|
||||
@@ -31,6 +49,13 @@ export class ColorSelectorComponent {
|
||||
|
||||
// Helper to find hex for the current selected value
|
||||
getCurrentHex(): string {
|
||||
// Check in dynamic variants first
|
||||
const vars = this.variants();
|
||||
if (vars && vars.length > 0) {
|
||||
const found = vars.find(v => v.colorName === this.selectedColor());
|
||||
if (found) return found.hexColor;
|
||||
}
|
||||
|
||||
return getColorHex(this.selectedColor());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<div class="success-state">
|
||||
<div class="success-icon">
|
||||
<svg 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">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||||
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
@switch (context()) {
|
||||
@case ('contact') {
|
||||
<h3>{{ 'CONTACT.SUCCESS_TITLE' | translate }}</h3>
|
||||
<p>{{ 'CONTACT.SUCCESS_DESC' | translate }}</p>
|
||||
<app-button (click)="action.emit()">{{ 'CONTACT.SEND_ANOTHER' | translate }}</app-button>
|
||||
}
|
||||
@case ('calc') {
|
||||
<h3>{{ 'CALC.ORDER_SUCCESS_TITLE' | translate }}</h3>
|
||||
<p>{{ 'CALC.ORDER_SUCCESS_DESC' | translate }}</p>
|
||||
<app-button (click)="action.emit()">{{ 'CALC.NEW_QUOTE' | translate }}</app-button>
|
||||
}
|
||||
@case ('shop') {
|
||||
<h3>{{ 'SHOP.SUCCESS_TITLE' | translate }}</h3>
|
||||
<p>{{ 'SHOP.SUCCESS_DESC' | translate }}</p>
|
||||
<app-button (click)="action.emit()">{{ 'SHOP.CONTINUE' | translate }}</app-button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,36 @@
|
||||
.success-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: var(--space-8) var(--space-4);
|
||||
gap: var(--space-4);
|
||||
min-height: 300px; /* Ensure visual balance */
|
||||
|
||||
.success-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
color: var(--color-success, #10b981);
|
||||
margin-bottom: var(--space-2);
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--color-text-muted);
|
||||
max-width: 400px;
|
||||
margin-bottom: var(--space-4);
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { AppButtonComponent } from '../app-button/app-button.component';
|
||||
|
||||
export type SuccessContext = 'contact' | 'calc' | 'shop';
|
||||
|
||||
@Component({
|
||||
selector: 'app-success-state',
|
||||
standalone: true,
|
||||
imports: [CommonModule, TranslateModule, AppButtonComponent],
|
||||
templateUrl: './success-state.component.html',
|
||||
styleUrl: './success-state.component.scss'
|
||||
})
|
||||
export class SuccessStateComponent {
|
||||
context = input.required<SuccessContext>();
|
||||
action = output<void>();
|
||||
}
|
||||
@@ -61,6 +61,9 @@
|
||||
"ORDER": "Order Now",
|
||||
"CONSULT": "Request Consultation",
|
||||
"ERROR_GENERIC": "An error occurred while calculating the quote.",
|
||||
"NEW_QUOTE": "Calculate New Quote",
|
||||
"ORDER_SUCCESS_TITLE": "Order Submitted Successfully",
|
||||
"ORDER_SUCCESS_DESC": "We have received your order details. You will receive a confirmation email shortly with the payment details.",
|
||||
"BENEFITS_TITLE": "Why choose us?",
|
||||
"BENEFITS_1": "Automatic quote with instant cost and time",
|
||||
"BENEFITS_2": "Selected materials and quality control",
|
||||
@@ -100,6 +103,21 @@
|
||||
"ADDRESS_BIENNE": "Bienne Office, Switzerland",
|
||||
"CONTACT_US": "Contact Us"
|
||||
},
|
||||
"LEGAL": {
|
||||
"PRIVACY_TITLE": "Privacy Policy",
|
||||
"TERMS_TITLE": "Terms and Conditions",
|
||||
"LAST_UPDATE": "Last update",
|
||||
"PRIVACY": {
|
||||
"SECTION_1": "1. Data Collection",
|
||||
"SECTION_2": "2. Purpose of Processing",
|
||||
"SECTION_3": "3. Cookies and Tracking"
|
||||
},
|
||||
"TERMS": {
|
||||
"SECTION_1": "1. Terms of Use",
|
||||
"SECTION_2": "2. Orders and Payments",
|
||||
"SECTION_3": "3. Refunds and Returns"
|
||||
}
|
||||
},
|
||||
"CONTACT": {
|
||||
"TITLE": "Contact Us",
|
||||
"SEND": "Send Message",
|
||||
@@ -126,6 +144,9 @@
|
||||
"LABEL_EMAIL": "Email *",
|
||||
"LABEL_NAME": "Name *",
|
||||
"MSG_SENT": "Sent!",
|
||||
"ERR_MAX_FILES": "Max 15 files limit reached."
|
||||
"ERR_MAX_FILES": "Max 15 files limit reached.",
|
||||
"SUCCESS_TITLE": "Message Sent Successfully",
|
||||
"SUCCESS_DESC": "Thank you for contacting us. We have received your message and will send you a recap email shortly.",
|
||||
"SEND_ANOTHER": "Send Another Message"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@
|
||||
"ORDER": "Ordina Ora",
|
||||
"CONSULT": "Richiedi Consulenza",
|
||||
"ERROR_GENERIC": "Si è verificato un errore durante il calcolo del preventivo.",
|
||||
"NEW_QUOTE": "Calcola Nuovo Preventivo",
|
||||
"ORDER_SUCCESS_TITLE": "Ordine Inviato con Successo",
|
||||
"ORDER_SUCCESS_DESC": "Abbiamo ricevuto i dettagli del tuo ordine. Riceverai a breve una email di conferma con i dettagli per il pagamento.",
|
||||
"BENEFITS_TITLE": "Perché scegliere noi?",
|
||||
"BENEFITS_1": "Preventivo automatico con costo e tempo immediati",
|
||||
"BENEFITS_2": "Materiali selezionati e qualità controllata",
|
||||
@@ -79,6 +82,21 @@
|
||||
"ADDRESS_BIENNE": "Sede Bienne, Svizzera",
|
||||
"CONTACT_US": "Contattaci"
|
||||
},
|
||||
"LEGAL": {
|
||||
"PRIVACY_TITLE": "Privacy Policy",
|
||||
"TERMS_TITLE": "Termini e Condizioni",
|
||||
"LAST_UPDATE": "Ultimo aggiornamento",
|
||||
"PRIVACY": {
|
||||
"SECTION_1": "1. Raccolta dei Dati",
|
||||
"SECTION_2": "2. Finalità del Trattamento",
|
||||
"SECTION_3": "3. Cookie e Tracciamento"
|
||||
},
|
||||
"TERMS": {
|
||||
"SECTION_1": "1. Condizioni d'Uso",
|
||||
"SECTION_2": "2. Ordini e Pagamenti",
|
||||
"SECTION_3": "3. Rimborsi e Resi"
|
||||
}
|
||||
},
|
||||
"CONTACT": {
|
||||
"TITLE": "Contattaci",
|
||||
"SEND": "Invia Messaggio",
|
||||
@@ -105,6 +123,9 @@
|
||||
"LABEL_EMAIL": "Email *",
|
||||
"LABEL_NAME": "Nome *",
|
||||
"MSG_SENT": "Inviato!",
|
||||
"ERR_MAX_FILES": "Limite massimo di 15 file raggiunto."
|
||||
"ERR_MAX_FILES": "Limite massimo di 15 file raggiunto.",
|
||||
"SUCCESS_TITLE": "Messaggio Inviato con Successo",
|
||||
"SUCCESS_DESC": "Grazie per averci contattato. Abbiamo ricevuto il tuo messaggio e ti invieremo una email di riepilogo a breve.",
|
||||
"SEND_ANOTHER": "Invia un altro messaggio"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
apiUrl: 'https://3d-fab.ch',
|
||||
apiUrl: '',
|
||||
basicAuth: ''
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
apiUrl: 'https://dev.3d-fab.ch',
|
||||
apiUrl: 'http://localhost:8000',
|
||||
basicAuth: 'fab:0presura' // Format: 'username:password'
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user