feat(back-end): new stock db and back-office improvements
This commit is contained in:
@@ -2,6 +2,7 @@ export interface ColorOption {
|
||||
label: string;
|
||||
value: string;
|
||||
hex: string;
|
||||
variantId?: number;
|
||||
outOfStock?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,48 +14,171 @@
|
||||
|
||||
<div class="content" *ngIf="!loading; else loadingTpl">
|
||||
<section class="panel">
|
||||
<h3>Inserimento rapido</h3>
|
||||
<div class="create-grid">
|
||||
<section class="subpanel">
|
||||
<h4>Nuovo materiale</h4>
|
||||
<div class="form-grid">
|
||||
<label class="form-field form-field--wide">
|
||||
<span>Codice materiale</span>
|
||||
<input type="text" [(ngModel)]="newMaterial.materialCode" placeholder="PLA, PETG, TPU..." />
|
||||
</label>
|
||||
<label class="form-field form-field--wide">
|
||||
<span>Etichetta tecnico</span>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newMaterial.technicalTypeLabel"
|
||||
[disabled]="!newMaterial.isTechnical"
|
||||
placeholder="alta temperatura, rinforzato..."
|
||||
/>
|
||||
</label>
|
||||
<div class="panel-header">
|
||||
<h3>Inserimento rapido</h3>
|
||||
<button type="button" class="panel-toggle" (click)="toggleQuickInsertCollapsed()">
|
||||
{{ quickInsertCollapsed ? 'Espandi' : 'Collassa' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!quickInsertCollapsed; else quickInsertCollapsedTpl">
|
||||
<div class="create-grid">
|
||||
<section class="subpanel">
|
||||
<h4>Nuovo materiale</h4>
|
||||
<div class="form-grid">
|
||||
<label class="form-field form-field--wide">
|
||||
<span>Codice materiale</span>
|
||||
<input type="text" [(ngModel)]="newMaterial.materialCode" placeholder="PLA, PETG, TPU..." />
|
||||
</label>
|
||||
<label class="form-field form-field--wide">
|
||||
<span>Etichetta tecnico</span>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newMaterial.technicalTypeLabel"
|
||||
[disabled]="!newMaterial.isTechnical"
|
||||
placeholder="alta temperatura, rinforzato..."
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" [(ngModel)]="newMaterial.isFlexible" />
|
||||
<span>Flessibile</span>
|
||||
</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" [(ngModel)]="newMaterial.isTechnical" />
|
||||
<span>Tecnico</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="button" (click)="createMaterial()" [disabled]="creatingMaterial">
|
||||
{{ creatingMaterial ? 'Salvataggio...' : 'Aggiungi materiale' }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="subpanel">
|
||||
<h4>Nuova variante</h4>
|
||||
<div class="form-grid">
|
||||
<label class="form-field">
|
||||
<span>Materiale</span>
|
||||
<select [(ngModel)]="newVariant.materialTypeId">
|
||||
<option *ngFor="let material of materials; trackBy: trackById" [ngValue]="material.id">
|
||||
{{ material.materialCode }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Nome variante</span>
|
||||
<input type="text" [(ngModel)]="newVariant.variantDisplayName" placeholder="PLA Nero Opaco BrandX" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Colore</span>
|
||||
<input type="text" [(ngModel)]="newVariant.colorName" placeholder="Nero, Bianco..." />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Hex colore</span>
|
||||
<input type="text" [(ngModel)]="newVariant.colorHex" placeholder="#1A1A1A" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Finitura</span>
|
||||
<select [(ngModel)]="newVariant.finishType">
|
||||
<option value="GLOSSY">GLOSSY</option>
|
||||
<option value="MATTE">MATTE</option>
|
||||
<option value="MARBLE">MARBLE</option>
|
||||
<option value="SILK">SILK</option>
|
||||
<option value="TRANSLUCENT">TRANSLUCENT</option>
|
||||
<option value="SPECIAL">SPECIAL</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Brand</span>
|
||||
<input type="text" [(ngModel)]="newVariant.brand" placeholder="Bambu, SUNLU..." />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Costo CHF/kg</span>
|
||||
<input type="number" step="0.01" min="0" [(ngModel)]="newVariant.costChfPerKg" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Stock spool</span>
|
||||
<input type="number" step="0.001" min="0" max="999.999" [(ngModel)]="newVariant.stockSpools" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Spool netto kg</span>
|
||||
<input type="number" step="0.001" min="0.001" max="999.999" [(ngModel)]="newVariant.spoolNetKg" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" [(ngModel)]="newVariant.isMatte" />
|
||||
<span>Matte</span>
|
||||
</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" [(ngModel)]="newVariant.isSpecial" />
|
||||
<span>Special</span>
|
||||
</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" [(ngModel)]="newVariant.isActive" />
|
||||
<span>Attiva</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p class="variant-meta">
|
||||
Stock spools: <strong>{{ newVariant.stockSpools | number:'1.0-3' }}</strong> |
|
||||
Filamento totale: <strong>{{ computeStockFilamentGrams(newVariant.stockSpools, newVariant.spoolNetKg) | number:'1.0-0' }} g</strong>
|
||||
</p>
|
||||
|
||||
<button type="button" (click)="createVariant()" [disabled]="creatingVariant || !materials.length">
|
||||
{{ creatingVariant ? 'Salvataggio...' : 'Aggiungi variante' }}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h3>Varianti filamento</h3>
|
||||
<div class="variant-list">
|
||||
<article class="variant-row" *ngFor="let variant of variants; trackBy: trackById">
|
||||
<div class="variant-header">
|
||||
<button
|
||||
type="button"
|
||||
class="expand-toggle"
|
||||
(click)="toggleVariantExpanded(variant.id)"
|
||||
[attr.aria-expanded]="isVariantExpanded(variant.id)">
|
||||
{{ isVariantExpanded(variant.id) ? '▾' : '▸' }}
|
||||
</button>
|
||||
|
||||
<div class="variant-head-main">
|
||||
<strong>{{ variant.variantDisplayName }}</strong>
|
||||
<div class="variant-collapsed-summary" *ngIf="!isVariantExpanded(variant.id)">
|
||||
<span class="color-summary">
|
||||
<span class="color-dot" [style.background-color]="getVariantColorHex(variant)"></span>
|
||||
{{ variant.colorName || 'N/D' }}
|
||||
</span>
|
||||
<span>Stock spools: {{ variant.stockSpools | number:'1.0-3' }}</span>
|
||||
<span>Filamento: {{ computeStockFilamentGrams(variant.stockSpools, variant.spoolNetKg) | number:'1.0-0' }} g</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="variant-head-actions">
|
||||
<span class="badge low" *ngIf="isLowStock(variant)">Stock basso</span>
|
||||
<span class="badge ok" *ngIf="!isLowStock(variant)">Stock ok</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-delete"
|
||||
(click)="openDeleteVariant(variant)"
|
||||
[disabled]="deletingVariantIds.has(variant.id)">
|
||||
{{ deletingVariantIds.has(variant.id) ? 'Eliminazione...' : 'Elimina' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" [(ngModel)]="newMaterial.isFlexible" />
|
||||
<span>Flessibile</span>
|
||||
</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" [(ngModel)]="newMaterial.isTechnical" />
|
||||
<span>Tecnico</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="button" (click)="createMaterial()" [disabled]="creatingMaterial">
|
||||
{{ creatingMaterial ? 'Salvataggio...' : 'Aggiungi materiale' }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="subpanel">
|
||||
<h4>Nuova variante</h4>
|
||||
<div class="form-grid">
|
||||
<div class="form-grid" *ngIf="isVariantExpanded(variant.id)">
|
||||
<label class="form-field">
|
||||
<span>Materiale</span>
|
||||
<select [(ngModel)]="newVariant.materialTypeId">
|
||||
<select [(ngModel)]="variant.materialTypeId">
|
||||
<option *ngFor="let material of materials; trackBy: trackById" [ngValue]="material.id">
|
||||
{{ material.materialCode }}
|
||||
</option>
|
||||
@@ -63,50 +186,75 @@
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Nome variante</span>
|
||||
<input type="text" [(ngModel)]="newVariant.variantDisplayName" placeholder="PLA Nero Opaco BrandX" />
|
||||
<input type="text" [(ngModel)]="variant.variantDisplayName" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Colore</span>
|
||||
<input type="text" [(ngModel)]="newVariant.colorName" placeholder="Nero, Bianco..." />
|
||||
<input type="text" [(ngModel)]="variant.colorName" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Hex colore</span>
|
||||
<input type="text" [(ngModel)]="variant.colorHex" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Finitura</span>
|
||||
<select [(ngModel)]="variant.finishType">
|
||||
<option value="GLOSSY">GLOSSY</option>
|
||||
<option value="MATTE">MATTE</option>
|
||||
<option value="MARBLE">MARBLE</option>
|
||||
<option value="SILK">SILK</option>
|
||||
<option value="TRANSLUCENT">TRANSLUCENT</option>
|
||||
<option value="SPECIAL">SPECIAL</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Brand</span>
|
||||
<input type="text" [(ngModel)]="variant.brand" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Costo CHF/kg</span>
|
||||
<input type="number" step="0.01" min="0" [(ngModel)]="newVariant.costChfPerKg" />
|
||||
<input type="number" step="0.01" min="0" [(ngModel)]="variant.costChfPerKg" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Stock spool</span>
|
||||
<input type="number" step="0.001" min="0" max="999.999" [(ngModel)]="newVariant.stockSpools" />
|
||||
<input type="number" step="0.001" min="0" max="999.999" [(ngModel)]="variant.stockSpools" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Spool netto kg</span>
|
||||
<input type="number" step="0.001" min="0.001" max="999.999" [(ngModel)]="newVariant.spoolNetKg" />
|
||||
<input type="number" step="0.001" min="0.001" max="999.999" [(ngModel)]="variant.spoolNetKg" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="toggle-group">
|
||||
<div class="toggle-group" *ngIf="isVariantExpanded(variant.id)">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" [(ngModel)]="newVariant.isMatte" />
|
||||
<input type="checkbox" [(ngModel)]="variant.isMatte" />
|
||||
<span>Matte</span>
|
||||
</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" [(ngModel)]="newVariant.isSpecial" />
|
||||
<input type="checkbox" [(ngModel)]="variant.isSpecial" />
|
||||
<span>Special</span>
|
||||
</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" [(ngModel)]="newVariant.isActive" />
|
||||
<input type="checkbox" [(ngModel)]="variant.isActive" />
|
||||
<span>Attiva</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p class="variant-meta">
|
||||
Stock stimato: <strong>{{ computeStockKg(newVariant.stockSpools, newVariant.spoolNetKg) | number:'1.0-3' }} kg</strong>
|
||||
<p class="variant-meta" *ngIf="isVariantExpanded(variant.id)">
|
||||
Stock spools: <strong>{{ variant.stockSpools | number:'1.0-3' }}</strong> |
|
||||
Filamento totale: <strong>{{ computeStockFilamentGrams(variant.stockSpools, variant.spoolNetKg) | number:'1.0-0' }} g</strong>
|
||||
</p>
|
||||
|
||||
<button type="button" (click)="createVariant()" [disabled]="creatingVariant || !materials.length">
|
||||
{{ creatingVariant ? 'Salvataggio...' : 'Aggiungi variante' }}
|
||||
<button
|
||||
type="button"
|
||||
*ngIf="isVariantExpanded(variant.id)"
|
||||
(click)="saveVariant(variant)"
|
||||
[disabled]="savingVariantIds.has(variant.id)">
|
||||
{{ savingVariantIds.has(variant.id) ? 'Salvataggio...' : 'Salva variante' }}
|
||||
</button>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
<p class="muted" *ngIf="variants.length === 0">Nessuna variante configurata.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
@@ -150,74 +298,6 @@
|
||||
<p class="muted" *ngIf="materials.length === 0">Nessun materiale configurato.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h3>Varianti filamento</h3>
|
||||
<div class="variant-grid">
|
||||
<article class="variant-card" *ngFor="let variant of variants; trackBy: trackById">
|
||||
<div class="variant-header">
|
||||
<strong>{{ variant.variantDisplayName }}</strong>
|
||||
<span class="badge low" *ngIf="isLowStock(variant)">Stock basso</span>
|
||||
<span class="badge ok" *ngIf="!isLowStock(variant)">Stock ok</span>
|
||||
</div>
|
||||
|
||||
<div class="form-grid">
|
||||
<label class="form-field">
|
||||
<span>Materiale</span>
|
||||
<select [(ngModel)]="variant.materialTypeId">
|
||||
<option *ngFor="let material of materials; trackBy: trackById" [ngValue]="material.id">
|
||||
{{ material.materialCode }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Nome variante</span>
|
||||
<input type="text" [(ngModel)]="variant.variantDisplayName" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Colore</span>
|
||||
<input type="text" [(ngModel)]="variant.colorName" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Costo CHF/kg</span>
|
||||
<input type="number" step="0.01" min="0" [(ngModel)]="variant.costChfPerKg" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Stock spool</span>
|
||||
<input type="number" step="0.001" min="0" max="999.999" [(ngModel)]="variant.stockSpools" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Spool netto kg</span>
|
||||
<input type="number" step="0.001" min="0.001" max="999.999" [(ngModel)]="variant.spoolNetKg" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" [(ngModel)]="variant.isMatte" />
|
||||
<span>Matte</span>
|
||||
</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" [(ngModel)]="variant.isSpecial" />
|
||||
<span>Special</span>
|
||||
</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" [(ngModel)]="variant.isActive" />
|
||||
<span>Attiva</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p class="variant-meta">
|
||||
Totale stimato: <strong>{{ computeStockKg(variant.stockSpools, variant.spoolNetKg) | number:'1.0-3' }} kg</strong>
|
||||
</p>
|
||||
|
||||
<button type="button" (click)="saveVariant(variant)" [disabled]="savingVariantIds.has(variant.id)">
|
||||
{{ savingVariantIds.has(variant.id) ? 'Salvataggio...' : 'Salva variante' }}
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
<p class="muted" *ngIf="variants.length === 0">Nessuna variante configurata.</p>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -228,3 +308,24 @@
|
||||
<ng-template #materialsCollapsedTpl>
|
||||
<p class="muted">Sezione collassata ({{ materials.length }} materiali).</p>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #quickInsertCollapsedTpl>
|
||||
<p class="muted">Sezione collassata.</p>
|
||||
</ng-template>
|
||||
|
||||
<div class="dialog-backdrop" *ngIf="variantToDelete" (click)="closeDeleteVariantDialog()"></div>
|
||||
<div class="confirm-dialog" *ngIf="variantToDelete">
|
||||
<h4>Sei sicuro?</h4>
|
||||
<p>Vuoi eliminare la variante <strong>{{ variantToDelete?.variantDisplayName }}</strong>?</p>
|
||||
<p class="muted">L'operazione non è reversibile.</p>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" class="btn-secondary" (click)="closeDeleteVariantDialog()">Annulla</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-delete"
|
||||
(click)="confirmDeleteVariant()"
|
||||
[disabled]="variantToDelete && deletingVariantIds.has(variantToDelete.id)">
|
||||
{{ variantToDelete && deletingVariantIds.has(variantToDelete.id) ? 'Eliminazione...' : 'Conferma elimina' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -141,24 +141,30 @@ select:disabled {
|
||||
}
|
||||
|
||||
.material-grid,
|
||||
.variant-grid {
|
||||
.variant-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.material-card,
|
||||
.variant-card {
|
||||
.variant-row {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-neutral-100);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.material-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
}
|
||||
|
||||
.variant-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.variant-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
@@ -167,6 +173,57 @@ select:disabled {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.variant-head-main {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.variant-collapsed-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.color-summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.color-dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--color-border);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.variant-head-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.expand-toggle {
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.expand-toggle:hover:not(:disabled) {
|
||||
background: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
.variant-meta {
|
||||
margin: 0 0 var(--space-3);
|
||||
font-size: 0.9rem;
|
||||
@@ -203,6 +260,25 @@ button:disabled {
|
||||
background: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: #dc3545;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-delete:hover:not(:disabled) {
|
||||
background: #bb2d3b;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
border-radius: 999px;
|
||||
@@ -236,6 +312,43 @@ button:disabled {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.dialog-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.32);
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
.confirm-dialog {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: min(460px, calc(100vw - 2rem));
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: var(--space-4);
|
||||
z-index: 1101;
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.confirm-dialog h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.confirm-dialog p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.create-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
AdminUpsertFilamentVariantPayload
|
||||
} from '../services/admin-operations.service';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { getColorHex } from '../../../core/constants/colors.const';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-filament-stock',
|
||||
@@ -23,11 +24,15 @@ export class AdminFilamentStockComponent implements OnInit {
|
||||
materials: AdminFilamentMaterialType[] = [];
|
||||
variants: AdminFilamentVariant[] = [];
|
||||
loading = false;
|
||||
quickInsertCollapsed = false;
|
||||
materialsCollapsed = true;
|
||||
creatingMaterial = false;
|
||||
creatingVariant = false;
|
||||
savingMaterialIds = new Set<number>();
|
||||
savingVariantIds = new Set<number>();
|
||||
deletingVariantIds = new Set<number>();
|
||||
expandedVariantIds = new Set<number>();
|
||||
variantToDelete: AdminFilamentVariant | null = null;
|
||||
errorMessage: string | null = null;
|
||||
successMessage: string | null = null;
|
||||
|
||||
@@ -42,6 +47,9 @@ export class AdminFilamentStockComponent implements OnInit {
|
||||
materialTypeId: 0,
|
||||
variantDisplayName: '',
|
||||
colorName: '',
|
||||
colorHex: '',
|
||||
finishType: 'GLOSSY',
|
||||
brand: '',
|
||||
isMatte: false,
|
||||
isSpecial: false,
|
||||
costChfPerKg: 0,
|
||||
@@ -66,6 +74,12 @@ export class AdminFilamentStockComponent implements OnInit {
|
||||
next: ({ materials, variants }) => {
|
||||
this.materials = this.sortMaterials(materials);
|
||||
this.variants = this.sortVariants(variants);
|
||||
const existingIds = new Set(this.variants.map(v => v.id));
|
||||
this.expandedVariantIds.forEach(id => {
|
||||
if (!existingIds.has(id)) {
|
||||
this.expandedVariantIds.delete(id);
|
||||
}
|
||||
});
|
||||
if (!this.newVariant.materialTypeId && this.materials.length > 0) {
|
||||
this.newVariant.materialTypeId = this.materials[0].id;
|
||||
}
|
||||
@@ -178,6 +192,9 @@ export class AdminFilamentStockComponent implements OnInit {
|
||||
materialTypeId: this.newVariant.materialTypeId || this.materials[0]?.id || 0,
|
||||
variantDisplayName: '',
|
||||
colorName: '',
|
||||
colorHex: '',
|
||||
finishType: 'GLOSSY',
|
||||
brand: '',
|
||||
isMatte: false,
|
||||
isSpecial: false,
|
||||
costChfPerKg: 0,
|
||||
@@ -221,7 +238,7 @@ export class AdminFilamentStockComponent implements OnInit {
|
||||
}
|
||||
|
||||
isLowStock(variant: AdminFilamentVariant): boolean {
|
||||
return this.computeStockKg(variant.stockSpools, variant.spoolNetKg) < 1;
|
||||
return this.computeStockFilamentGrams(variant.stockSpools, variant.spoolNetKg) < 1000;
|
||||
}
|
||||
|
||||
computeStockKg(stockSpools?: number, spoolNetKg?: number): number {
|
||||
@@ -234,19 +251,82 @@ export class AdminFilamentStockComponent implements OnInit {
|
||||
return spools * netKg;
|
||||
}
|
||||
|
||||
computeStockFilamentGrams(stockSpools?: number, spoolNetKg?: number): number {
|
||||
return this.computeStockKg(stockSpools, spoolNetKg) * 1000;
|
||||
}
|
||||
|
||||
trackById(index: number, item: { id: number }): number {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
isVariantExpanded(variantId: number): boolean {
|
||||
return this.expandedVariantIds.has(variantId);
|
||||
}
|
||||
|
||||
toggleVariantExpanded(variantId: number): void {
|
||||
if (this.expandedVariantIds.has(variantId)) {
|
||||
this.expandedVariantIds.delete(variantId);
|
||||
return;
|
||||
}
|
||||
this.expandedVariantIds.add(variantId);
|
||||
}
|
||||
|
||||
getVariantColorHex(variant: AdminFilamentVariant): string {
|
||||
if (variant.colorHex && variant.colorHex.trim().length > 0) {
|
||||
return variant.colorHex;
|
||||
}
|
||||
return getColorHex(variant.colorName || '');
|
||||
}
|
||||
|
||||
openDeleteVariant(variant: AdminFilamentVariant): void {
|
||||
this.variantToDelete = variant;
|
||||
}
|
||||
|
||||
closeDeleteVariantDialog(): void {
|
||||
this.variantToDelete = null;
|
||||
}
|
||||
|
||||
confirmDeleteVariant(): void {
|
||||
const variant = this.variantToDelete;
|
||||
if (!variant || this.deletingVariantIds.has(variant.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.errorMessage = null;
|
||||
this.successMessage = null;
|
||||
this.deletingVariantIds.add(variant.id);
|
||||
|
||||
this.adminOperationsService.deleteFilamentVariant(variant.id).subscribe({
|
||||
next: () => {
|
||||
this.variants = this.variants.filter(v => v.id !== variant.id);
|
||||
this.expandedVariantIds.delete(variant.id);
|
||||
this.deletingVariantIds.delete(variant.id);
|
||||
this.variantToDelete = null;
|
||||
this.successMessage = 'Variante eliminata.';
|
||||
},
|
||||
error: (err) => {
|
||||
this.deletingVariantIds.delete(variant.id);
|
||||
this.errorMessage = this.extractErrorMessage(err, 'Eliminazione variante non riuscita.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleMaterialsCollapsed(): void {
|
||||
this.materialsCollapsed = !this.materialsCollapsed;
|
||||
}
|
||||
|
||||
toggleQuickInsertCollapsed(): void {
|
||||
this.quickInsertCollapsed = !this.quickInsertCollapsed;
|
||||
}
|
||||
|
||||
private toVariantPayload(source: AdminUpsertFilamentVariantPayload | AdminFilamentVariant): AdminUpsertFilamentVariantPayload {
|
||||
return {
|
||||
materialTypeId: Number(source.materialTypeId),
|
||||
variantDisplayName: (source.variantDisplayName || '').trim(),
|
||||
colorName: (source.colorName || '').trim(),
|
||||
colorHex: (source.colorHex || '').trim() || undefined,
|
||||
finishType: (source.finishType || 'GLOSSY').trim().toUpperCase(),
|
||||
brand: (source.brand || '').trim() || undefined,
|
||||
isMatte: !!source.isMatte,
|
||||
isSpecial: !!source.isSpecial,
|
||||
costChfPerKg: Number(source.costChfPerKg ?? 0),
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface AdminFilamentStockRow {
|
||||
stockSpools: number;
|
||||
spoolNetKg: number;
|
||||
stockKg: number;
|
||||
stockFilamentGrams: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
@@ -31,12 +32,16 @@ export interface AdminFilamentVariant {
|
||||
materialTechnicalTypeLabel?: string;
|
||||
variantDisplayName: string;
|
||||
colorName: string;
|
||||
colorHex?: string;
|
||||
finishType?: string;
|
||||
brand?: string;
|
||||
isMatte: boolean;
|
||||
isSpecial: boolean;
|
||||
costChfPerKg: number;
|
||||
stockSpools: number;
|
||||
spoolNetKg: number;
|
||||
stockKg: number;
|
||||
stockFilamentGrams: number;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -52,6 +57,9 @@ export interface AdminUpsertFilamentVariantPayload {
|
||||
materialTypeId: number;
|
||||
variantDisplayName: string;
|
||||
colorName: string;
|
||||
colorHex?: string;
|
||||
finishType?: string;
|
||||
brand?: string;
|
||||
isMatte: boolean;
|
||||
isSpecial: boolean;
|
||||
costChfPerKg: number;
|
||||
@@ -167,6 +175,10 @@ export class AdminOperationsService {
|
||||
return this.http.put<AdminFilamentVariant>(`${this.baseUrl}/filaments/variants/${variantId}`, payload, { withCredentials: true });
|
||||
}
|
||||
|
||||
deleteFilamentVariant(variantId: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.baseUrl}/filaments/variants/${variantId}`, { withCredentials: true });
|
||||
}
|
||||
|
||||
getContactRequests(): Observable<AdminContactRequest[]> {
|
||||
return this.http.get<AdminContactRequest[]>(`${this.baseUrl}/contact-requests`, { withCredentials: true });
|
||||
}
|
||||
|
||||
@@ -135,7 +135,10 @@ export class CalculatorPageComponent implements OnInit {
|
||||
// Assuming index matches.
|
||||
// Need to be careful if items order changed, but usually ID sort or insert order.
|
||||
if (item.colorCode) {
|
||||
this.uploadForm.updateItemColor(index, item.colorCode);
|
||||
this.uploadForm.updateItemColor(index, {
|
||||
colorName: item.colorCode,
|
||||
filamentVariantId: item.filamentVariantId
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
<label>{{ 'CALC.COLOR_LABEL' | translate }}</label>
|
||||
<app-color-selector
|
||||
[selectedColor]="item.color"
|
||||
[selectedVariantId]="item.filamentVariantId ?? null"
|
||||
[variants]="currentMaterialVariants()"
|
||||
(colorSelected)="updateItemColor(i, $event)">
|
||||
</app-color-selector>
|
||||
|
||||
@@ -15,6 +15,7 @@ interface FormItem {
|
||||
file: File;
|
||||
quantity: number;
|
||||
color: string;
|
||||
filamentVariantId?: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -58,6 +59,7 @@ export class UploadFormComponent implements OnInit {
|
||||
if (matCode && this.fullMaterialOptions.length > 0) {
|
||||
const found = this.fullMaterialOptions.find(m => m.code === matCode);
|
||||
this.currentMaterialVariants.set(found ? found.variants : []);
|
||||
this.syncItemVariantSelections();
|
||||
} else {
|
||||
this.currentMaterialVariants.set([]);
|
||||
}
|
||||
@@ -166,8 +168,13 @@ export class UploadFormComponent implements OnInit {
|
||||
if (file.size > MAX_SIZE) {
|
||||
hasError = true;
|
||||
} else {
|
||||
// Default color is Black
|
||||
validItems.push({ file, quantity: 1, color: 'Black' });
|
||||
const defaultSelection = this.getDefaultVariantSelection();
|
||||
validItems.push({
|
||||
file,
|
||||
quantity: 1,
|
||||
color: defaultSelection.colorName,
|
||||
filamentVariantId: defaultSelection.filamentVariantId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,7 +227,9 @@ export class UploadFormComponent implements OnInit {
|
||||
if (item) {
|
||||
const vars = this.currentMaterialVariants();
|
||||
if (vars && vars.length > 0) {
|
||||
const found = vars.find(v => v.colorName === item.color);
|
||||
const found = item.filamentVariantId
|
||||
? vars.find(v => v.id === item.filamentVariantId)
|
||||
: vars.find(v => v.colorName === item.color);
|
||||
if (found) return found.hexColor;
|
||||
}
|
||||
return getColorHex(item.color);
|
||||
@@ -240,10 +249,12 @@ export class UploadFormComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
updateItemColor(index: number, newColor: string) {
|
||||
updateItemColor(index: number, newSelection: string | { colorName: string; filamentVariantId?: number }) {
|
||||
const colorName = typeof newSelection === 'string' ? newSelection : newSelection.colorName;
|
||||
const filamentVariantId = typeof newSelection === 'string' ? undefined : newSelection.filamentVariantId;
|
||||
this.items.update(current => {
|
||||
const updated = [...current];
|
||||
updated[index] = { ...updated[index], color: newColor };
|
||||
updated[index] = { ...updated[index], color: colorName, filamentVariantId };
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
@@ -261,9 +272,14 @@ export class UploadFormComponent implements OnInit {
|
||||
|
||||
setFiles(files: File[]) {
|
||||
const validItems: FormItem[] = [];
|
||||
const defaultSelection = this.getDefaultVariantSelection();
|
||||
for (const file of files) {
|
||||
// Default color is Black or derive from somewhere if possible, but here we just init
|
||||
validItems.push({ file, quantity: 1, color: 'Black' });
|
||||
validItems.push({
|
||||
file,
|
||||
quantity: 1,
|
||||
color: defaultSelection.colorName,
|
||||
filamentVariantId: defaultSelection.filamentVariantId
|
||||
});
|
||||
}
|
||||
|
||||
if (validItems.length > 0) {
|
||||
@@ -274,6 +290,39 @@ export class UploadFormComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
private getDefaultVariantSelection(): { colorName: string; filamentVariantId?: number } {
|
||||
const vars = this.currentMaterialVariants();
|
||||
if (vars && vars.length > 0) {
|
||||
const preferred = vars.find(v => !v.isOutOfStock) || vars[0];
|
||||
return {
|
||||
colorName: preferred.colorName,
|
||||
filamentVariantId: preferred.id
|
||||
};
|
||||
}
|
||||
return { colorName: 'Black' };
|
||||
}
|
||||
|
||||
private syncItemVariantSelections(): void {
|
||||
const vars = this.currentMaterialVariants();
|
||||
if (!vars || vars.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fallback = vars.find(v => !v.isOutOfStock) || vars[0];
|
||||
this.items.update(current => current.map(item => {
|
||||
const byId = item.filamentVariantId != null
|
||||
? vars.find(v => v.id === item.filamentVariantId)
|
||||
: null;
|
||||
const byColor = vars.find(v => v.colorName === item.color);
|
||||
const selected = byId || byColor || fallback;
|
||||
return {
|
||||
...item,
|
||||
color: selected.colorName,
|
||||
filamentVariantId: selected.id
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
patchSettings(settings: any) {
|
||||
if (!settings) return;
|
||||
// settings object matches keys in our form?
|
||||
|
||||
@@ -5,7 +5,7 @@ import { map, catchError, tap } from 'rxjs/operators';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
|
||||
export interface QuoteRequest {
|
||||
items: { file: File, quantity: number, color?: string }[];
|
||||
items: { file: File, quantity: number, color?: string, filamentVariantId?: number }[];
|
||||
material: string;
|
||||
quality: string;
|
||||
notes?: string;
|
||||
@@ -26,6 +26,7 @@ export interface QuoteItem {
|
||||
quantity: number;
|
||||
material?: string;
|
||||
color?: string;
|
||||
filamentVariantId?: number;
|
||||
}
|
||||
|
||||
export interface QuoteResult {
|
||||
@@ -72,9 +73,13 @@ export interface MaterialOption {
|
||||
variants: VariantOption[];
|
||||
}
|
||||
export interface VariantOption {
|
||||
id: number;
|
||||
name: string;
|
||||
colorName: string;
|
||||
hexColor: string;
|
||||
finishType: string;
|
||||
stockSpools: number;
|
||||
stockFilamentGrams: number;
|
||||
isOutOfStock: boolean;
|
||||
}
|
||||
export interface QualityOption {
|
||||
@@ -250,6 +255,7 @@ export class QuoteEstimatorService {
|
||||
const settings = {
|
||||
complexityMode: request.mode === 'easy' ? 'ADVANCED' : request.mode.toUpperCase(),
|
||||
material: request.material,
|
||||
filamentVariantId: item.filamentVariantId,
|
||||
quality: easyPreset ? easyPreset.quality : request.quality,
|
||||
supportsEnabled: request.supportEnabled,
|
||||
color: item.color || '#FFFFFF',
|
||||
@@ -351,7 +357,8 @@ export class QuoteEstimatorService {
|
||||
material: session.materialCode, // Assumption: session has one material for all? or items have it?
|
||||
// Backend model QuoteSession has materialCode.
|
||||
// But line items might have different colors.
|
||||
color: item.colorCode
|
||||
color: item.colorCode,
|
||||
filamentVariantId: item.filamentVariantId
|
||||
})),
|
||||
setupCost: session.setupCostChf || 0,
|
||||
globalMachineCost: sessionData.globalMachineCostChf || 0,
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
[class.disabled]="color.outOfStock">
|
||||
|
||||
<div class="selection-ring"
|
||||
[class.active]="selectedColor() === color.value"
|
||||
[class.active]="selectedVariantId() ? selectedVariantId() === color.variantId : selectedColor() === color.value"
|
||||
[class.out-of-stock]="color.outOfStock">
|
||||
<div class="color-circle small" [style.background-color]="color.hex"></div>
|
||||
</div>
|
||||
|
||||
@@ -13,25 +13,33 @@ import { VariantOption } from '../../../features/calculator/services/quote-estim
|
||||
})
|
||||
export class ColorSelectorComponent {
|
||||
selectedColor = input<string>('Black');
|
||||
selectedVariantId = input<number | null>(null);
|
||||
variants = input<VariantOption[]>([]);
|
||||
colorSelected = output<string>();
|
||||
colorSelected = output<{ colorName: string; filamentVariantId?: number }>();
|
||||
|
||||
isOpen = signal(false);
|
||||
|
||||
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: 'COLOR.AVAILABLE_COLORS',
|
||||
colors: vars.map(v => ({
|
||||
label: v.colorName, // Display "Red"
|
||||
value: v.colorName, // Send "Red" to backend
|
||||
const byFinish = new Map<string, ColorOption[]>();
|
||||
vars.forEach(v => {
|
||||
const finish = v.finishType || 'AVAILABLE_COLORS';
|
||||
const bucket = byFinish.get(finish) || [];
|
||||
bucket.push({
|
||||
label: v.colorName,
|
||||
value: v.colorName,
|
||||
hex: v.hexColor,
|
||||
variantId: v.id,
|
||||
outOfStock: v.isOutOfStock
|
||||
}))
|
||||
}] as ColorCategory[];
|
||||
});
|
||||
byFinish.set(finish, bucket);
|
||||
});
|
||||
|
||||
return Array.from(byFinish.entries()).map(([finish, colors]) => ({
|
||||
name: finish,
|
||||
colors
|
||||
})) as ColorCategory[];
|
||||
}
|
||||
return PRODUCT_COLORS;
|
||||
});
|
||||
@@ -42,8 +50,11 @@ export class ColorSelectorComponent {
|
||||
|
||||
selectColor(color: ColorOption) {
|
||||
if (color.outOfStock) return;
|
||||
|
||||
this.colorSelected.emit(color.value);
|
||||
|
||||
this.colorSelected.emit({
|
||||
colorName: color.value,
|
||||
filamentVariantId: color.variantId
|
||||
});
|
||||
this.isOpen.set(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -133,8 +133,6 @@
|
||||
"TITLE": "Über uns",
|
||||
"EYEBROW": "3D-Druck-Labor",
|
||||
"SUBTITLE": "Wir sind zwei Studenten mit viel Motivation und Lernbereitschaft.",
|
||||
"HOW_TEXT": "3D Fab entstand aus Matteos anfänglichem Interesse am 3D-Druck. Er kaufte einen Drucker und begann ernsthaft zu experimentieren.\nIrgendwann kamen die ersten Anfragen: ein gebrochenes Teil zum Ersetzen, ein Ersatzteil, das man nicht findet, ein praktischer Adapter. Die Anfragen nahmen zu und wir sagten uns: okay, machen wir es richtig.\nSpäter haben wir einen Rechner entwickelt, um die Kosten im Voraus zu verstehen: das war einer der ersten Schritte vom „wir machen ein paar Teile“ zu einem echten Projekt – gemeinsam.",
|
||||
"PASSIONS_TITLE": "Unsere Leidenschaften",
|
||||
"PASSION_BIKE_TRIAL": "Bike Trial",
|
||||
"PASSION_MOUNTAIN": "Berge",
|
||||
"PASSION_SKI": "Ski",
|
||||
|
||||
@@ -133,8 +133,6 @@
|
||||
"TITLE": "About Us",
|
||||
"EYEBROW": "3D Printing Lab",
|
||||
"SUBTITLE": "We are two students with a strong desire to build and learn.",
|
||||
"HOW_TEXT": "3D Fab was born from Matteo's initial interest in 3D printing. He bought a printer and started experimenting seriously. \n At a certain point, the first requests arrived: a broken part to replace, a spare part that cannot be found, a handy adapter to have. The requests increased and we said: okay, let's do it properly.\nLater we created a calculator to understand the cost in advance: it was one of the first steps that took us from \"let's make a few parts\" to a real project, together.",
|
||||
"PASSIONS_TITLE": "Our passions",
|
||||
"PASSION_BIKE_TRIAL": "Bike trial",
|
||||
"PASSION_MOUNTAIN": "Mountain",
|
||||
"PASSION_SKI": "Ski",
|
||||
|
||||
@@ -190,8 +190,6 @@
|
||||
"TITLE": "Qui sommes-nous",
|
||||
"EYEBROW": "Atelier d'impression 3D",
|
||||
"SUBTITLE": "Nous sommes deux étudiants avec beaucoup d'envie de faire et d'apprendre.",
|
||||
"HOW_TEXT": "3D Fab est né de l'intérêt initial de Matteo pour l'impression 3D. Il a acheté une imprimante et a commencé à expérimenter sérieusement. \n À un certain moment, les premières demandes sont arrivées : une pièce cassée à remplacer, une pièce de rechange introuvable, un adaptateur pratique à avoir. Les demandes ont augmenté et nous nous sommes dit : d'accord, faisons-le bien.\nEnsuite, nous avons créé un calculateur pour connaître le coût à l'avance : cela a été l'un des premiers pas qui nous a fait passer de « on fait quelques pièces » à un vrai projet, ensemble.",
|
||||
"PASSIONS_TITLE": "Nos passions",
|
||||
"PASSION_BIKE_TRIAL": "Bike trial",
|
||||
"PASSION_MOUNTAIN": "Montagne",
|
||||
"PASSION_SKI": "Ski",
|
||||
|
||||
@@ -190,8 +190,8 @@
|
||||
"TITLE": "Chi Siamo",
|
||||
"EYEBROW": "Laboratorio di stampa 3D",
|
||||
"SUBTITLE": "Siamo due studenti con tanta voglia di fare e di imparare.",
|
||||
"HOW_TEXT": "3D Fab nasce dall’interesse iniziale di Matteo per la stampa 3D. Ha comprato una stampante e ha iniziato a sperimentare sul serio. \n A un certo punto sono arrivate le prime richieste: un pezzo rotto da sostituire, un ricambio che non si trova, un adattatore comodo da avere. Le richieste sono aumentate e ci siamo detti: ok, facciamolo bene.\nIn seguito abbiamo creato un calcolatore per capire il costo in anticipo: è stato uno dei primi passi che ci ha fatto passare dal “facciamo qualche pezzo” a un progetto vero, insieme.",
|
||||
"PASSIONS_TITLE": "Le nostre passioni",
|
||||
"HOW_TEXT": "3D Fab nasce per trasformare le potenzialità della stampa 3D in soluzioni quotidiane. Siamo partiti dalla curiosità tecnica e siamo arrivati alla produzione di ricambi, prodotti e prototipi su misura. Per passare da un'idea a un progetto concreto abbiamo lanciato il nostro calcolatore automatico: preventivi chiari in un clic per garantirti un servizio professionale e senza sorprese sul prezzo.",
|
||||
"PASSIONS_TITLE": "I nostri interessi",
|
||||
"PASSION_BIKE_TRIAL": "Bike trial",
|
||||
"PASSION_MOUNTAIN": "Montagna",
|
||||
"PASSION_SKI": "Ski",
|
||||
|
||||
Reference in New Issue
Block a user