1987 lines
52 KiB
Vue
1987 lines
52 KiB
Vue
<template>
|
|
<form class="reception-form" @submit.prevent="submitForm">
|
|
<!-- Progress Stepper -->
|
|
<div class="form-stepper">
|
|
<div class="stepper-line"></div>
|
|
<div
|
|
class="step"
|
|
:class="{ active: currentStep >= 1, done: currentStep > 1 }"
|
|
>
|
|
<div class="step-dot"><i class="fas fa-file-alt"></i></div>
|
|
<span class="step-label">Informations</span>
|
|
</div>
|
|
<div
|
|
class="step"
|
|
:class="{ active: currentStep >= 2, done: currentStep > 2 }"
|
|
>
|
|
<div class="step-dot"><i class="fas fa-boxes"></i></div>
|
|
<span class="step-label">Articles</span>
|
|
</div>
|
|
<div class="step" :class="{ active: currentStep >= 3 }">
|
|
<div class="step-dot"><i class="fas fa-check"></i></div>
|
|
<span class="step-label">Validation</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Section 1: Informations générales -->
|
|
<div class="form-card" :class="{ collapsed: currentStep > 1 }">
|
|
<div class="card-header" @click="currentStep = 1">
|
|
<div class="card-header-left">
|
|
<div class="card-icon"><i class="fas fa-file-invoice"></i></div>
|
|
<div>
|
|
<h3 class="card-title">Informations générales</h3>
|
|
<p class="card-subtitle">
|
|
Commande, entrepôt et détails de réception
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="card-header-right">
|
|
<SoftBadge
|
|
v-if="currentStep > 1"
|
|
color="success"
|
|
variant="gradient"
|
|
size="sm"
|
|
class="badge-complete-soft"
|
|
>
|
|
<i class="fas fa-check-circle"></i> Complété
|
|
</SoftBadge>
|
|
<i
|
|
class="fas fa-chevron-down card-toggle"
|
|
:class="{ rotated: currentStep === 1 }"
|
|
></i>
|
|
</div>
|
|
</div>
|
|
|
|
<transition name="slide-down">
|
|
<div v-show="currentStep === 1" class="card-body">
|
|
<!-- Row 1: Commande + Entrepôt -->
|
|
<div class="field-grid two-col">
|
|
<div
|
|
class="field-group"
|
|
:class="{ 'has-error': errors.purchase_order_id }"
|
|
>
|
|
<label class="field-label">
|
|
<i class="fas fa-shopping-cart label-icon"></i>
|
|
Commande Fournisseur
|
|
<span class="required-dot"></span>
|
|
</label>
|
|
<div class="select-wrapper">
|
|
<select
|
|
v-model="formData.purchase_order_id"
|
|
class="field-select"
|
|
>
|
|
<option value="">— Sélectionner une commande —</option>
|
|
<option
|
|
v-for="po in purchaseOrders"
|
|
:key="po.id"
|
|
:value="po.id"
|
|
>
|
|
{{ po.po_number }} ·
|
|
{{
|
|
po.fournisseur?.nom || "Fournisseur " + po.fournisseur_id
|
|
}}
|
|
</option>
|
|
</select>
|
|
<i class="fas fa-chevron-down select-chevron"></i>
|
|
</div>
|
|
<transition name="shake">
|
|
<span v-if="errors.purchase_order_id" class="field-error">
|
|
<i class="fas fa-exclamation-circle"></i>
|
|
{{ errors.purchase_order_id }}
|
|
</span>
|
|
</transition>
|
|
</div>
|
|
|
|
<div
|
|
class="field-group position-relative warehouse-search-container"
|
|
:class="{ 'has-error': errors.warehouse_id }"
|
|
>
|
|
<label class="field-label">
|
|
<i class="fas fa-warehouse label-icon"></i>
|
|
Entrepôt de Destination
|
|
<span class="required-dot"></span>
|
|
</label>
|
|
<div class="search-wrapper">
|
|
<i class="fas fa-search search-icon"></i>
|
|
<input
|
|
v-model="warehouseSearchQuery"
|
|
type="text"
|
|
class="field-input search-input"
|
|
placeholder="Rechercher un entrepôt…"
|
|
@input="handleWarehouseSearch"
|
|
@focus="showWarehouseResults = true"
|
|
/>
|
|
<transition name="fade">
|
|
<button
|
|
v-if="warehouseSearchQuery && formData.warehouse_id"
|
|
type="button"
|
|
class="clear-btn"
|
|
@click="clearWarehouse"
|
|
>
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
</transition>
|
|
</div>
|
|
|
|
<transition name="dropdown">
|
|
<div
|
|
v-if="
|
|
showWarehouseResults &&
|
|
(warehouseSearchResults.length > 0 || isSearchingWarehouses)
|
|
"
|
|
class="results-dropdown"
|
|
>
|
|
<div v-if="isSearchingWarehouses" class="dropdown-loading">
|
|
<div class="loading-dots">
|
|
<span></span><span></span><span></span>
|
|
</div>
|
|
<span>Recherche…</span>
|
|
</div>
|
|
<template v-else>
|
|
<button
|
|
v-for="warehouse in warehouseSearchResults"
|
|
:key="warehouse.id"
|
|
type="button"
|
|
class="result-item"
|
|
@click="selectWarehouse(warehouse)"
|
|
>
|
|
<div class="result-icon">
|
|
<i class="fas fa-warehouse"></i>
|
|
</div>
|
|
<div class="result-info">
|
|
<span class="result-name">{{ warehouse.name }}</span>
|
|
<span class="result-meta"
|
|
>{{ warehouse.city || "Ville non spécifiée" }} ·
|
|
{{ warehouse.country_code || "" }}</span
|
|
>
|
|
</div>
|
|
</button>
|
|
</template>
|
|
</div>
|
|
</transition>
|
|
|
|
<transition name="shake">
|
|
<span v-if="errors.warehouse_id" class="field-error">
|
|
<i class="fas fa-exclamation-circle"></i>
|
|
{{ errors.warehouse_id }}
|
|
</span>
|
|
</transition>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Row 2: Numéro, Date, Statut -->
|
|
<div class="field-grid three-col">
|
|
<div class="field-group">
|
|
<label class="field-label">
|
|
<i class="fas fa-hashtag label-icon"></i>
|
|
Numéro de Réception
|
|
</label>
|
|
<input
|
|
v-model="formData.receipt_number"
|
|
type="text"
|
|
class="field-input"
|
|
placeholder="Auto-généré si vide"
|
|
/>
|
|
<span class="field-hint"
|
|
>Laissez vide pour génération automatique</span
|
|
>
|
|
</div>
|
|
|
|
<div class="field-group">
|
|
<label class="field-label">
|
|
<i class="fas fa-calendar-day label-icon"></i>
|
|
Date de Réception
|
|
<span class="required-dot"></span>
|
|
</label>
|
|
<input
|
|
v-model="formData.receipt_date"
|
|
type="date"
|
|
class="field-input"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div class="field-group">
|
|
<label class="field-label">
|
|
<i class="fas fa-tag label-icon"></i>
|
|
Statut
|
|
</label>
|
|
<div class="status-toggle">
|
|
<SoftButton
|
|
type="button"
|
|
color="warning"
|
|
size="sm"
|
|
:variant="
|
|
formData.status === 'draft' ? 'gradient' : 'outline'
|
|
"
|
|
:active="formData.status === 'draft'"
|
|
@click="formData.status = 'draft'"
|
|
>
|
|
Brouillon
|
|
</SoftButton>
|
|
<SoftButton
|
|
type="button"
|
|
color="success"
|
|
size="sm"
|
|
:variant="
|
|
formData.status === 'posted' ? 'gradient' : 'outline'
|
|
"
|
|
:active="formData.status === 'posted'"
|
|
@click="formData.status = 'posted'"
|
|
>
|
|
<i class="fas fa-check-double"></i> Validée
|
|
</SoftButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Notes -->
|
|
<div class="field-group">
|
|
<label class="field-label">
|
|
<i class="fas fa-sticky-note label-icon"></i>
|
|
Notes internes
|
|
</label>
|
|
<textarea
|
|
v-model="formData.notes"
|
|
class="field-textarea"
|
|
placeholder="Informations complémentaires sur cette réception…"
|
|
rows="3"
|
|
></textarea>
|
|
</div>
|
|
|
|
<div class="card-actions">
|
|
<SoftButton
|
|
type="button"
|
|
color="primary"
|
|
variant="gradient"
|
|
class="soft-action-btn"
|
|
@click="goToStep2"
|
|
>
|
|
Continuer vers les articles
|
|
<i class="fas fa-arrow-right"></i>
|
|
</SoftButton>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
</div>
|
|
|
|
<!-- Section 2: Lignes de réception -->
|
|
<div class="form-card" :class="{ collapsed: currentStep < 2 }">
|
|
<div class="card-header" @click="currentStep >= 2 && (currentStep = 2)">
|
|
<div class="card-header-left">
|
|
<div class="card-icon card-icon--blue">
|
|
<i class="fas fa-boxes"></i>
|
|
</div>
|
|
<div>
|
|
<h3 class="card-title">Lignes de Réception</h3>
|
|
<p class="card-subtitle">
|
|
{{ formData.lines.length }} article{{
|
|
formData.lines.length > 1 ? "s" : ""
|
|
}}
|
|
ajouté{{ formData.lines.length > 1 ? "s" : "" }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="card-header-right">
|
|
<SoftBadge
|
|
v-if="currentStep > 2"
|
|
color="success"
|
|
variant="gradient"
|
|
size="sm"
|
|
class="badge-complete-soft"
|
|
>
|
|
<i class="fas fa-check-circle"></i> Complété
|
|
</SoftBadge>
|
|
<i
|
|
v-if="currentStep >= 2"
|
|
class="fas fa-chevron-down card-toggle"
|
|
:class="{ rotated: currentStep === 2 }"
|
|
></i>
|
|
</div>
|
|
</div>
|
|
|
|
<transition name="slide-down">
|
|
<div v-show="currentStep === 2" class="card-body">
|
|
<!-- Lines Table Header -->
|
|
<div class="lines-header">
|
|
<span class="col-product">Produit</span>
|
|
<span class="col-pkg">Conditionnement</span>
|
|
<span class="col-colis">Colis Reçus</span>
|
|
<span class="col-units">Unités Reçues</span>
|
|
<span class="col-price">Prix Unitaire</span>
|
|
<span class="col-actions"></span>
|
|
</div>
|
|
|
|
<transition-group name="line-list" tag="div" class="lines-list">
|
|
<div
|
|
v-for="(line, index) in formData.lines"
|
|
:key="index"
|
|
class="line-row"
|
|
>
|
|
<!-- Product Search -->
|
|
<div
|
|
class="cell cell-product position-relative product-search-container"
|
|
>
|
|
<div class="search-wrapper">
|
|
<i class="fas fa-cube search-icon-sm"></i>
|
|
<input
|
|
v-model="line.searchQuery"
|
|
type="text"
|
|
class="field-input field-input--sm"
|
|
placeholder="Rechercher un produit…"
|
|
@input="handleProductSearch(index)"
|
|
@focus="
|
|
activeLineIndex = index;
|
|
showProductResults = true;
|
|
"
|
|
/>
|
|
</div>
|
|
<transition name="dropdown">
|
|
<div
|
|
v-show="showProductResults && activeLineIndex === index"
|
|
class="results-dropdown results-dropdown--sm"
|
|
>
|
|
<div v-if="isSearchingProducts" class="dropdown-loading">
|
|
<div class="loading-dots">
|
|
<span></span><span></span><span></span>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-else-if="productSearchResults.length === 0"
|
|
class="dropdown-empty"
|
|
>
|
|
<i class="fas fa-search-minus"></i> Aucun produit trouvé
|
|
</div>
|
|
<template v-else>
|
|
<button
|
|
v-for="product in productSearchResults"
|
|
:key="product.id"
|
|
type="button"
|
|
class="result-item result-item--sm"
|
|
@mousedown.prevent="selectProduct(index, product)"
|
|
>
|
|
<div class="result-icon result-icon--sm">
|
|
<i class="fas fa-cube"></i>
|
|
</div>
|
|
<div class="result-info">
|
|
<span class="result-name">{{ product.nom }}</span>
|
|
<span class="result-meta"
|
|
>Réf: {{ product.reference }} · Stock:
|
|
{{ product.stock_actuel }} {{ product.unite }}</span
|
|
>
|
|
</div>
|
|
</button>
|
|
</template>
|
|
</div>
|
|
</transition>
|
|
</div>
|
|
|
|
<!-- Packaging -->
|
|
<div class="cell cell-pkg">
|
|
<div class="select-wrapper select-wrapper--sm">
|
|
<select
|
|
v-model="line.packaging_id"
|
|
class="field-select field-select--sm"
|
|
>
|
|
<option :value="null">Unité</option>
|
|
<option
|
|
v-for="pkg in getPackagings(line.product_id)"
|
|
:key="pkg.id"
|
|
:value="pkg.id"
|
|
>
|
|
{{ pkg.name }} ({{ pkg.qty_base }})
|
|
</option>
|
|
</select>
|
|
<i
|
|
class="fas fa-chevron-down select-chevron select-chevron--sm"
|
|
></i>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Packages qty -->
|
|
<div class="cell cell-number">
|
|
<input
|
|
v-model.number="line.packages_qty_received"
|
|
type="number"
|
|
class="field-input field-input--sm field-input--number"
|
|
placeholder="0"
|
|
min="0"
|
|
step="0.001"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Units qty -->
|
|
<div class="cell cell-number">
|
|
<input
|
|
v-model.number="line.units_qty_received"
|
|
type="number"
|
|
class="field-input field-input--sm field-input--number"
|
|
placeholder="0"
|
|
min="0"
|
|
step="0.001"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Unit Price -->
|
|
<div class="cell cell-number">
|
|
<div class="price-input-wrapper">
|
|
<span class="currency-symbol">€</span>
|
|
<input
|
|
v-model.number="line.unit_price"
|
|
type="number"
|
|
class="field-input field-input--sm field-input--price"
|
|
placeholder="0.00"
|
|
min="0"
|
|
step="0.01"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="cell cell-actions">
|
|
<SoftButton
|
|
type="button"
|
|
class="line-delete-btn"
|
|
color="danger"
|
|
variant="outline"
|
|
size="sm"
|
|
:disabled="formData.lines.length === 1"
|
|
title="Supprimer"
|
|
@click="removeLine(index)"
|
|
>
|
|
<i class="fas fa-trash"></i>
|
|
</SoftButton>
|
|
</div>
|
|
</div>
|
|
</transition-group>
|
|
|
|
<!-- Add Line + Totals -->
|
|
<div class="lines-footer">
|
|
<SoftButton
|
|
type="button"
|
|
color="primary"
|
|
variant="outline"
|
|
size="sm"
|
|
class="btn-add-line-soft"
|
|
@click="addLine"
|
|
>
|
|
<i class="fas fa-plus-circle"></i>
|
|
Ajouter une ligne
|
|
</SoftButton>
|
|
<div class="lines-summary">
|
|
<div class="summary-item">
|
|
<span class="summary-label">Total lignes</span>
|
|
<span class="summary-value">{{
|
|
formData.lines.filter((l) => l.product_id).length
|
|
}}</span>
|
|
</div>
|
|
<div class="summary-item">
|
|
<span class="summary-label">Total unités</span>
|
|
<span class="summary-value">{{ totalUnits }}</span>
|
|
</div>
|
|
<div class="summary-item summary-item--total">
|
|
<span class="summary-label">Montant total</span>
|
|
<span class="summary-value">{{ totalAmount }} €</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card-actions card-actions--spread">
|
|
<SoftButton
|
|
type="button"
|
|
color="secondary"
|
|
variant="outline"
|
|
class="soft-action-btn"
|
|
@click="currentStep = 1"
|
|
>
|
|
<i class="fas fa-arrow-left"></i> Retour
|
|
</SoftButton>
|
|
<SoftButton
|
|
type="button"
|
|
color="primary"
|
|
variant="gradient"
|
|
class="soft-action-btn"
|
|
@click="goToStep3"
|
|
>
|
|
Vérifier et enregistrer
|
|
<i class="fas fa-arrow-right"></i>
|
|
</SoftButton>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
</div>
|
|
|
|
<!-- Section 3: Récapitulatif & Validation -->
|
|
<div class="form-card" :class="{ collapsed: currentStep < 3 }">
|
|
<div class="card-header">
|
|
<div class="card-header-left">
|
|
<div class="card-icon card-icon--green">
|
|
<i class="fas fa-clipboard-check"></i>
|
|
</div>
|
|
<div>
|
|
<h3 class="card-title">Récapitulatif</h3>
|
|
<p class="card-subtitle">Vérifiez avant d'enregistrer</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<transition name="slide-down">
|
|
<div v-show="currentStep === 3" class="card-body">
|
|
<div class="recap-grid">
|
|
<div class="recap-item">
|
|
<span class="recap-label"
|
|
><i class="fas fa-shopping-cart"></i> Commande</span
|
|
>
|
|
<span class="recap-value">{{ selectedPOLabel }}</span>
|
|
</div>
|
|
<div class="recap-item">
|
|
<span class="recap-label"
|
|
><i class="fas fa-warehouse"></i> Entrepôt</span
|
|
>
|
|
<span class="recap-value">{{ warehouseSearchQuery || "—" }}</span>
|
|
</div>
|
|
<div class="recap-item">
|
|
<span class="recap-label"
|
|
><i class="fas fa-calendar-day"></i> Date</span
|
|
>
|
|
<span class="recap-value">{{ formData.receipt_date }}</span>
|
|
</div>
|
|
<div class="recap-item">
|
|
<span class="recap-label"><i class="fas fa-tag"></i> Statut</span>
|
|
<span class="recap-value">
|
|
<SoftBadge
|
|
:color="formData.status === 'draft' ? 'warning' : 'success'"
|
|
variant="gradient"
|
|
size="sm"
|
|
>
|
|
{{ formData.status === "draft" ? "Brouillon" : "Validée" }}
|
|
</SoftBadge>
|
|
</span>
|
|
</div>
|
|
<div class="recap-item">
|
|
<span class="recap-label"
|
|
><i class="fas fa-boxes"></i> Articles</span
|
|
>
|
|
<span class="recap-value"
|
|
>{{
|
|
formData.lines.filter((l) => l.product_id).length
|
|
}}
|
|
produit(s)</span
|
|
>
|
|
</div>
|
|
<div class="recap-item">
|
|
<span class="recap-label"
|
|
><i class="fas fa-coins"></i> Montant total</span
|
|
>
|
|
<span class="recap-value recap-total">{{ totalAmount }} €</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card-actions card-actions--spread">
|
|
<SoftButton
|
|
type="button"
|
|
color="secondary"
|
|
variant="outline"
|
|
class="soft-action-btn"
|
|
@click="currentStep = 2"
|
|
>
|
|
<i class="fas fa-arrow-left"></i> Modifier
|
|
</SoftButton>
|
|
<div class="action-group">
|
|
<SoftButton
|
|
type="button"
|
|
color="danger"
|
|
variant="outline"
|
|
class="soft-action-btn"
|
|
@click="cancelForm"
|
|
>
|
|
<i class="fas fa-times"></i> Annuler
|
|
</SoftButton>
|
|
<SoftButton
|
|
type="submit"
|
|
color="primary"
|
|
variant="gradient"
|
|
class="soft-action-btn"
|
|
:disabled="props.loading"
|
|
>
|
|
<span v-if="props.loading" class="btn-spinner"></span>
|
|
<i v-else class="fas fa-check"></i>
|
|
{{
|
|
props.loading ? "Enregistrement…" : "Enregistrer la réception"
|
|
}}
|
|
</SoftButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
</div>
|
|
</form>
|
|
</template>
|
|
|
|
<script setup>
|
|
import {
|
|
ref,
|
|
watch,
|
|
computed,
|
|
defineEmits,
|
|
defineProps,
|
|
onMounted,
|
|
onUnmounted,
|
|
} from "vue";
|
|
import WarehouseService from "@/services/warehouse";
|
|
import ProductService from "@/services/product";
|
|
import { PurchaseOrderService } from "@/services/purchaseOrder";
|
|
import SoftButton from "@/components/SoftButton.vue";
|
|
import SoftBadge from "@/components/SoftBadge.vue";
|
|
|
|
const props = defineProps({
|
|
loading: { type: Boolean, default: false },
|
|
purchaseOrders: { type: Array, default: () => [] },
|
|
});
|
|
|
|
const emit = defineEmits(["submit", "cancel"]);
|
|
|
|
// Step management
|
|
const currentStep = ref(1);
|
|
|
|
// Warehouse Search
|
|
const warehouseSearchQuery = ref("");
|
|
const warehouseSearchResults = ref([]);
|
|
const isSearchingWarehouses = ref(false);
|
|
const showWarehouseResults = ref(false);
|
|
let searchTimeout = null;
|
|
|
|
const handleWarehouseSearch = () => {
|
|
if (warehouseSearchQuery.value.length < 2) {
|
|
warehouseSearchResults.value = [];
|
|
if (searchTimeout) clearTimeout(searchTimeout);
|
|
isSearchingWarehouses.value = false;
|
|
return;
|
|
}
|
|
if (searchTimeout) clearTimeout(searchTimeout);
|
|
searchTimeout = setTimeout(async () => {
|
|
isSearchingWarehouses.value = true;
|
|
showWarehouseResults.value = true;
|
|
try {
|
|
const results = await WarehouseService.searchWarehouses(
|
|
warehouseSearchQuery.value
|
|
);
|
|
warehouseSearchResults.value = results.filter((w) => w && w.id);
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
isSearchingWarehouses.value = false;
|
|
}
|
|
}, 300);
|
|
};
|
|
|
|
const selectWarehouse = (warehouse) => {
|
|
if (!warehouse?.id) return;
|
|
if (searchTimeout) clearTimeout(searchTimeout);
|
|
formData.value.warehouse_id = warehouse.id;
|
|
warehouseSearchQuery.value = warehouse.name;
|
|
showWarehouseResults.value = false;
|
|
};
|
|
|
|
const clearWarehouse = () => {
|
|
formData.value.warehouse_id = "";
|
|
warehouseSearchQuery.value = "";
|
|
warehouseSearchResults.value = [];
|
|
};
|
|
|
|
// Product Search
|
|
const productSearchResults = ref([]);
|
|
const isSearchingProducts = ref(false);
|
|
const showProductResults = ref(false);
|
|
const activeLineIndex = ref(null);
|
|
const productPackagings = ref({});
|
|
let productSearchTimeout = null;
|
|
|
|
const handleProductSearch = (index) => {
|
|
activeLineIndex.value = index;
|
|
const query = formData.value.lines[index].searchQuery;
|
|
if (query.length < 2) {
|
|
productSearchResults.value = [];
|
|
if (productSearchTimeout) clearTimeout(productSearchTimeout);
|
|
return;
|
|
}
|
|
if (productSearchTimeout) clearTimeout(productSearchTimeout);
|
|
productSearchTimeout = setTimeout(async () => {
|
|
isSearchingProducts.value = true;
|
|
showProductResults.value = true;
|
|
try {
|
|
const response = await ProductService.searchProducts(query);
|
|
let results = [];
|
|
if (response?.data) {
|
|
results = Array.isArray(response.data)
|
|
? response.data
|
|
: response.data.data || [];
|
|
}
|
|
productSearchResults.value = results.filter((p) => p && p.id);
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
isSearchingProducts.value = false;
|
|
}
|
|
}, 300);
|
|
};
|
|
|
|
const selectProduct = (index, product) => {
|
|
if (!product?.id) return;
|
|
if (productSearchTimeout) clearTimeout(productSearchTimeout);
|
|
const line = formData.value.lines[index];
|
|
if (!line) return;
|
|
line.product_id = product.id;
|
|
line.searchQuery = product.nom;
|
|
loadProductPackagings(product.id);
|
|
showProductResults.value = false;
|
|
activeLineIndex.value = null;
|
|
};
|
|
|
|
const loadProductPackagings = async (productId) => {
|
|
try {
|
|
const response = await ProductService.getProduct(productId);
|
|
if (response?.data) {
|
|
productPackagings.value[productId] = response.data.conditionnement
|
|
? [
|
|
{
|
|
id: 1,
|
|
name: response.data.conditionnement_nom || "Conditionnement",
|
|
qty_base: response.data.conditionnement_quantite || 1,
|
|
},
|
|
]
|
|
: [];
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
};
|
|
|
|
const getPackagings = (productId) => productPackagings.value[productId] || [];
|
|
|
|
// Click outside
|
|
const handleClickOutside = (event) => {
|
|
if (
|
|
!document
|
|
.querySelector(".warehouse-search-container")
|
|
?.contains(event.target)
|
|
) {
|
|
showWarehouseResults.value = false;
|
|
}
|
|
let insideProduct = false;
|
|
document.querySelectorAll(".product-search-container").forEach((c) => {
|
|
if (c.contains(event.target)) insideProduct = true;
|
|
});
|
|
if (!insideProduct) showProductResults.value = false;
|
|
};
|
|
|
|
onMounted(() => document.addEventListener("click", handleClickOutside));
|
|
onUnmounted(() => document.removeEventListener("click", handleClickOutside));
|
|
|
|
// Form Data
|
|
const formData = ref({
|
|
purchase_order_id: "",
|
|
warehouse_id: "",
|
|
receipt_number: "",
|
|
receipt_date: new Date().toISOString().split("T")[0],
|
|
status: "draft",
|
|
notes: "",
|
|
lines: [
|
|
{
|
|
product_id: "",
|
|
searchQuery: "",
|
|
packaging_id: null,
|
|
packages_qty_received: null,
|
|
units_qty_received: null,
|
|
unit_price: null,
|
|
},
|
|
],
|
|
});
|
|
|
|
const errors = ref({});
|
|
|
|
// Computed
|
|
const selectedPOLabel = computed(() => {
|
|
const po = props.purchaseOrders.find(
|
|
(p) => p.id == formData.value.purchase_order_id
|
|
);
|
|
return po
|
|
? `${po.po_number} · ${
|
|
po.fournisseur?.nom || "Fournisseur " + po.fournisseur_id
|
|
}`
|
|
: "—";
|
|
});
|
|
|
|
const totalUnits = computed(() =>
|
|
formData.value.lines.reduce(
|
|
(s, l) => s + (Number(l.units_qty_received) || 0),
|
|
0
|
|
)
|
|
);
|
|
|
|
const totalAmount = computed(() =>
|
|
formData.value.lines
|
|
.reduce(
|
|
(s, l) =>
|
|
s + (Number(l.units_qty_received) || 0) * (Number(l.unit_price) || 0),
|
|
0
|
|
)
|
|
.toFixed(2)
|
|
);
|
|
|
|
// Hydrate from PO
|
|
const hydrateLinesFromPurchaseOrder = async (purchaseOrderId) => {
|
|
if (!purchaseOrderId) return;
|
|
try {
|
|
const response = await PurchaseOrderService.getPurchaseOrder(
|
|
parseInt(purchaseOrderId)
|
|
);
|
|
const order = response?.data;
|
|
const orderLines = Array.isArray(order?.lines)
|
|
? order.lines
|
|
: Array.isArray(order?.lines?.data)
|
|
? order.lines.data
|
|
: [];
|
|
if (!orderLines.length) return;
|
|
formData.value.lines = orderLines.map((line) => ({
|
|
product_id: line.product_id || "",
|
|
searchQuery: line.product?.nom || line.description || "",
|
|
packaging_id: null,
|
|
packages_qty_received: null,
|
|
units_qty_received: Number(line.quantity || 0) || null,
|
|
unit_price: Number(line.unit_price || 0) || null,
|
|
}));
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
};
|
|
|
|
watch(
|
|
() => formData.value.purchase_order_id,
|
|
async (newId) => {
|
|
if (!newId) {
|
|
formData.value.lines = [
|
|
{
|
|
product_id: "",
|
|
searchQuery: "",
|
|
packaging_id: null,
|
|
packages_qty_received: null,
|
|
units_qty_received: null,
|
|
unit_price: null,
|
|
},
|
|
];
|
|
return;
|
|
}
|
|
await hydrateLinesFromPurchaseOrder(newId);
|
|
}
|
|
);
|
|
|
|
// Line management
|
|
const addLine = () => {
|
|
formData.value.lines.push({
|
|
product_id: "",
|
|
searchQuery: "",
|
|
packaging_id: null,
|
|
packages_qty_received: null,
|
|
units_qty_received: null,
|
|
unit_price: null,
|
|
});
|
|
};
|
|
|
|
const removeLine = (index) => {
|
|
if (formData.value.lines.length > 1) formData.value.lines.splice(index, 1);
|
|
};
|
|
|
|
// Navigation
|
|
const goToStep2 = () => {
|
|
errors.value = {};
|
|
if (!formData.value.purchase_order_id)
|
|
errors.value.purchase_order_id = "La commande fournisseur est requise.";
|
|
if (!formData.value.warehouse_id)
|
|
errors.value.warehouse_id = "L'entrepôt est requis.";
|
|
if (!Object.keys(errors.value).length) currentStep.value = 2;
|
|
};
|
|
|
|
const goToStep3 = () => {
|
|
currentStep.value = 3;
|
|
};
|
|
|
|
const cancelForm = () => emit("cancel");
|
|
|
|
const submitForm = () => {
|
|
errors.value = {};
|
|
if (!formData.value.purchase_order_id)
|
|
errors.value.purchase_order_id = "La commande fournisseur est requise.";
|
|
if (!formData.value.warehouse_id)
|
|
errors.value.warehouse_id = "L'entrepôt est requis.";
|
|
if (Object.keys(errors.value).length) {
|
|
currentStep.value = 1;
|
|
return;
|
|
}
|
|
|
|
emit("submit", {
|
|
purchase_order_id: parseInt(formData.value.purchase_order_id),
|
|
warehouse_id: parseInt(formData.value.warehouse_id),
|
|
receipt_number: formData.value.receipt_number || undefined,
|
|
receipt_date: formData.value.receipt_date,
|
|
status: formData.value.status,
|
|
notes: formData.value.notes || undefined,
|
|
lines: formData.value.lines
|
|
.map((line) => ({
|
|
product_id: parseInt(line.product_id),
|
|
packaging_id: line.packaging_id ? parseInt(line.packaging_id) : null,
|
|
packages_qty_received: line.packages_qty_received,
|
|
units_qty_received: line.units_qty_received,
|
|
unit_price: line.unit_price,
|
|
}))
|
|
.filter((line) => line.product_id),
|
|
});
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* ─────────────────────────────────────────────
|
|
FONTS & TOKENS
|
|
───────────────────────────────────────────── */
|
|
@import url("https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700&family=DM+Mono:wght@400;500&display=swap");
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.reception-form {
|
|
font-family: "DM Sans", sans-serif;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
max-width: 960px;
|
|
margin: 0 auto;
|
|
padding: 0.5rem;
|
|
|
|
--clr-primary: #5e72e4;
|
|
--clr-primary-light: #eef1ff;
|
|
--clr-primary-dark: #4b61da;
|
|
--clr-success: #12b886;
|
|
--clr-success-light: #e6f9f4;
|
|
--clr-danger: #f03e3e;
|
|
--clr-danger-light: #fff5f5;
|
|
--clr-warning: #f59f00;
|
|
--clr-border: #e8ecf0;
|
|
--clr-bg: #f7f8fc;
|
|
--clr-surface: #ffffff;
|
|
--clr-text: #212529;
|
|
--clr-text-muted: #868e96;
|
|
--clr-text-subtle: #adb5bd;
|
|
--radius-sm: 6px;
|
|
--radius-md: 10px;
|
|
--radius-lg: 14px;
|
|
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
|
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.08);
|
|
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.12);
|
|
--transition: 0.18s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────
|
|
STEPPER
|
|
───────────────────────────────────────────── */
|
|
.form-stepper {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0;
|
|
padding: 1.5rem 2rem 0.5rem;
|
|
position: relative;
|
|
}
|
|
|
|
.stepper-line {
|
|
position: absolute;
|
|
top: calc(1.5rem + 18px);
|
|
left: calc(50% - 120px);
|
|
width: 240px;
|
|
height: 2px;
|
|
background: var(--clr-border);
|
|
z-index: 0;
|
|
}
|
|
|
|
.step {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 6px;
|
|
flex: 1;
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
|
|
.step-dot {
|
|
width: 38px;
|
|
height: 38px;
|
|
border-radius: 50%;
|
|
background: var(--clr-surface);
|
|
border: 2px solid var(--clr-border);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--clr-text-subtle);
|
|
font-size: 0.8rem;
|
|
transition: all var(--transition);
|
|
}
|
|
|
|
.step.active .step-dot {
|
|
background: var(--clr-primary);
|
|
border-color: var(--clr-primary);
|
|
color: #fff;
|
|
box-shadow: 0 0 0 4px var(--clr-primary-light);
|
|
}
|
|
|
|
.step.done .step-dot {
|
|
background: var(--clr-success);
|
|
border-color: var(--clr-success);
|
|
color: #fff;
|
|
}
|
|
|
|
.step-label {
|
|
font-size: 0.7rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.04em;
|
|
text-transform: uppercase;
|
|
color: var(--clr-text-subtle);
|
|
transition: color var(--transition);
|
|
}
|
|
|
|
.step.active .step-label {
|
|
color: var(--clr-primary);
|
|
}
|
|
.step.done .step-label {
|
|
color: var(--clr-success);
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────
|
|
CARDS
|
|
───────────────────────────────────────────── */
|
|
.form-card {
|
|
background: var(--clr-surface);
|
|
border: 1.5px solid var(--clr-border);
|
|
border-radius: var(--radius-lg);
|
|
overflow: hidden;
|
|
transition: box-shadow var(--transition), border-color var(--transition);
|
|
}
|
|
|
|
.form-card:not(.collapsed) {
|
|
box-shadow: var(--shadow-md);
|
|
border-color: #d0d8ff;
|
|
}
|
|
|
|
.card-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 1.25rem 1.5rem;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
transition: background var(--transition);
|
|
}
|
|
|
|
.card-header:hover {
|
|
background: #fafbff;
|
|
}
|
|
|
|
.card-header-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.card-icon {
|
|
width: 44px;
|
|
height: 44px;
|
|
border-radius: var(--radius-md);
|
|
background: var(--clr-primary-light);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--clr-primary);
|
|
font-size: 1rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.card-icon--blue {
|
|
background: #e3f2fd;
|
|
color: #1976d2;
|
|
}
|
|
.card-icon--green {
|
|
background: var(--clr-success-light);
|
|
color: var(--clr-success);
|
|
}
|
|
|
|
.card-title {
|
|
margin: 0;
|
|
font-size: 0.95rem;
|
|
font-weight: 700;
|
|
color: var(--clr-text);
|
|
}
|
|
|
|
.card-subtitle {
|
|
margin: 2px 0 0;
|
|
font-size: 0.78rem;
|
|
color: var(--clr-text-muted);
|
|
}
|
|
|
|
.card-header-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.badge-complete {
|
|
font-size: 0.7rem;
|
|
font-weight: 600;
|
|
color: var(--clr-success);
|
|
background: var(--clr-success-light);
|
|
border-radius: 99px;
|
|
padding: 0.25rem 0.6rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.badge-complete-soft {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.card-toggle {
|
|
color: var(--clr-text-muted);
|
|
transition: transform var(--transition);
|
|
}
|
|
|
|
.card-toggle.rotated {
|
|
transform: rotate(180deg);
|
|
}
|
|
|
|
.card-body {
|
|
padding: 1.25rem 1.5rem 1.5rem;
|
|
border-top: 1.5px solid var(--clr-border);
|
|
background: #fafbff;
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────
|
|
FIELDS
|
|
───────────────────────────────────────────── */
|
|
.field-grid {
|
|
display: grid;
|
|
gap: 1.25rem;
|
|
margin-bottom: 1.25rem;
|
|
}
|
|
|
|
.two-col {
|
|
grid-template-columns: 1fr 1fr;
|
|
}
|
|
.three-col {
|
|
grid-template-columns: 1fr 1fr 1fr;
|
|
}
|
|
|
|
@media (max-width: 640px) {
|
|
.two-col,
|
|
.three-col {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
.field-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
.field-group.has-error .field-input,
|
|
.field-group.has-error .field-select {
|
|
border-color: var(--clr-danger) !important;
|
|
}
|
|
|
|
.field-label {
|
|
font-size: 0.78rem;
|
|
font-weight: 600;
|
|
color: #4a5568;
|
|
letter-spacing: 0.02em;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
}
|
|
|
|
.label-icon {
|
|
color: var(--clr-primary);
|
|
opacity: 0.7;
|
|
font-size: 0.7rem;
|
|
}
|
|
|
|
.required-dot {
|
|
width: 5px;
|
|
height: 5px;
|
|
border-radius: 50%;
|
|
background: var(--clr-danger);
|
|
margin-left: 2px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.field-input,
|
|
.field-select {
|
|
height: 40px;
|
|
padding: 0 12px;
|
|
border: 1.5px solid var(--clr-border);
|
|
border-radius: var(--radius-md);
|
|
background: var(--clr-surface);
|
|
font-family: "DM Sans", sans-serif;
|
|
font-size: 0.875rem;
|
|
color: var(--clr-text);
|
|
transition: border-color var(--transition), box-shadow var(--transition);
|
|
width: 100%;
|
|
outline: none;
|
|
}
|
|
|
|
.field-input:focus,
|
|
.field-select:focus {
|
|
border-color: var(--clr-primary);
|
|
box-shadow: 0 0 0 3px rgba(59, 91, 219, 0.12);
|
|
}
|
|
|
|
.field-input::placeholder {
|
|
color: var(--clr-text-subtle);
|
|
}
|
|
|
|
.field-input--sm {
|
|
height: 36px;
|
|
font-size: 0.8rem;
|
|
padding: 0 10px;
|
|
}
|
|
.field-input--number {
|
|
text-align: right;
|
|
font-family: "DM Mono", monospace;
|
|
}
|
|
.field-input--price {
|
|
padding-left: 28px;
|
|
text-align: right;
|
|
font-family: "DM Mono", monospace;
|
|
}
|
|
|
|
.field-select {
|
|
appearance: none;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.field-textarea {
|
|
padding: 10px 12px;
|
|
border: 1.5px solid var(--clr-border);
|
|
border-radius: var(--radius-md);
|
|
background: var(--clr-surface);
|
|
font-family: "DM Sans", sans-serif;
|
|
font-size: 0.875rem;
|
|
color: var(--clr-text);
|
|
resize: vertical;
|
|
width: 100%;
|
|
outline: none;
|
|
transition: border-color var(--transition), box-shadow var(--transition);
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.field-textarea:focus {
|
|
border-color: var(--clr-primary);
|
|
box-shadow: 0 0 0 3px rgba(59, 91, 219, 0.12);
|
|
}
|
|
|
|
.field-textarea::placeholder {
|
|
color: var(--clr-text-subtle);
|
|
}
|
|
|
|
.field-hint {
|
|
font-size: 0.72rem;
|
|
color: var(--clr-text-muted);
|
|
}
|
|
|
|
.field-error {
|
|
font-size: 0.75rem;
|
|
color: var(--clr-danger);
|
|
font-weight: 500;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────
|
|
SELECT WRAPPER
|
|
───────────────────────────────────────────── */
|
|
.select-wrapper {
|
|
position: relative;
|
|
}
|
|
.select-chevron {
|
|
position: absolute;
|
|
right: 10px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
color: var(--clr-text-muted);
|
|
pointer-events: none;
|
|
font-size: 0.65rem;
|
|
}
|
|
.select-wrapper--sm .field-select--sm {
|
|
height: 36px;
|
|
font-size: 0.8rem;
|
|
padding: 0 28px 0 8px;
|
|
}
|
|
.select-chevron--sm {
|
|
font-size: 0.6rem;
|
|
right: 8px;
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────
|
|
SEARCH WRAPPER
|
|
───────────────────────────────────────────── */
|
|
.search-wrapper {
|
|
position: relative;
|
|
}
|
|
|
|
.search-icon {
|
|
position: absolute;
|
|
left: 11px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
color: var(--clr-text-subtle);
|
|
pointer-events: none;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.search-wrapper .field-input {
|
|
padding-left: 34px;
|
|
}
|
|
|
|
.search-icon-sm {
|
|
font-size: 0.65rem;
|
|
left: 9px;
|
|
}
|
|
.search-wrapper .field-input--sm {
|
|
padding-left: 28px;
|
|
}
|
|
|
|
.clear-btn {
|
|
position: absolute;
|
|
right: 10px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
color: var(--clr-text-muted);
|
|
font-size: 0.7rem;
|
|
width: 18px;
|
|
height: 18px;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: background var(--transition), color var(--transition);
|
|
}
|
|
.clear-btn:hover {
|
|
background: #eee;
|
|
color: var(--clr-text);
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────
|
|
RESULTS DROPDOWN
|
|
───────────────────────────────────────────── */
|
|
.results-dropdown {
|
|
position: absolute;
|
|
top: calc(100% + 4px);
|
|
left: 0;
|
|
right: 0;
|
|
background: var(--clr-surface);
|
|
border: 1.5px solid var(--clr-border);
|
|
border-radius: var(--radius-md);
|
|
box-shadow: var(--shadow-lg);
|
|
max-height: 280px;
|
|
overflow-y: auto;
|
|
z-index: 1000;
|
|
scrollbar-width: thin;
|
|
}
|
|
|
|
.results-dropdown--sm {
|
|
max-height: 220px;
|
|
}
|
|
|
|
.result-item {
|
|
width: 100%;
|
|
padding: 10px 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
background: none;
|
|
border: none;
|
|
border-bottom: 1px solid #f1f3f5;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
transition: background var(--transition);
|
|
}
|
|
|
|
.result-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
.result-item:hover {
|
|
background: var(--clr-primary-light);
|
|
}
|
|
|
|
.result-item--sm {
|
|
padding: 8px 10px;
|
|
}
|
|
|
|
.result-icon {
|
|
width: 32px;
|
|
height: 32px;
|
|
border-radius: var(--radius-sm);
|
|
background: var(--clr-primary-light);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--clr-primary);
|
|
font-size: 0.75rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.result-icon--sm {
|
|
width: 26px;
|
|
height: 26px;
|
|
font-size: 0.65rem;
|
|
}
|
|
|
|
.result-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.result-name {
|
|
font-weight: 600;
|
|
font-size: 0.85rem;
|
|
color: var(--clr-text);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.result-meta {
|
|
font-size: 0.72rem;
|
|
color: var(--clr-text-muted);
|
|
}
|
|
|
|
.dropdown-loading {
|
|
padding: 1rem;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.5rem;
|
|
color: var(--clr-text-muted);
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.dropdown-empty {
|
|
padding: 1rem;
|
|
text-align: center;
|
|
color: var(--clr-text-muted);
|
|
font-size: 0.8rem;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
.loading-dots {
|
|
display: flex;
|
|
gap: 4px;
|
|
}
|
|
.loading-dots span {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
background: var(--clr-primary);
|
|
animation: bounce 1s infinite;
|
|
}
|
|
.loading-dots span:nth-child(2) {
|
|
animation-delay: 0.15s;
|
|
}
|
|
.loading-dots span:nth-child(3) {
|
|
animation-delay: 0.3s;
|
|
}
|
|
@keyframes bounce {
|
|
0%,
|
|
80%,
|
|
100% {
|
|
transform: scale(0.6);
|
|
opacity: 0.4;
|
|
}
|
|
40% {
|
|
transform: scale(1);
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────
|
|
STATUS TOGGLE
|
|
───────────────────────────────────────────── */
|
|
.status-toggle {
|
|
display: flex;
|
|
gap: 6px;
|
|
padding: 4px;
|
|
background: var(--clr-bg);
|
|
border: 1.5px solid var(--clr-border);
|
|
border-radius: var(--radius-md);
|
|
}
|
|
|
|
.status-btn {
|
|
flex: 1;
|
|
height: 32px;
|
|
border: none;
|
|
border-radius: var(--radius-sm);
|
|
background: none;
|
|
font-family: "DM Sans", sans-serif;
|
|
font-size: 0.78rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
color: var(--clr-text-muted);
|
|
transition: all var(--transition);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 5px;
|
|
}
|
|
|
|
.status-btn.active {
|
|
background: var(--clr-surface);
|
|
color: var(--clr-primary);
|
|
font-weight: 600;
|
|
box-shadow: var(--shadow-sm);
|
|
}
|
|
|
|
.status-toggle :deep(.btn) {
|
|
flex: 1;
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────
|
|
LINES TABLE
|
|
───────────────────────────────────────────── */
|
|
.lines-header {
|
|
display: grid;
|
|
grid-template-columns: 2.5fr 1.4fr 1fr 1fr 1fr 44px;
|
|
gap: 8px;
|
|
padding: 0 12px 8px;
|
|
font-size: 0.68rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
color: var(--clr-text-muted);
|
|
}
|
|
|
|
.lines-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
|
|
.line-row {
|
|
display: grid;
|
|
grid-template-columns: 2.5fr 1.4fr 1fr 1fr 1fr 44px;
|
|
gap: 8px;
|
|
align-items: center;
|
|
background: var(--clr-surface);
|
|
border: 1.5px solid var(--clr-border);
|
|
border-radius: var(--radius-md);
|
|
padding: 10px 12px;
|
|
transition: box-shadow var(--transition), border-color var(--transition);
|
|
}
|
|
|
|
.line-row:hover {
|
|
box-shadow: var(--shadow-sm);
|
|
border-color: #d0d8ff;
|
|
}
|
|
|
|
.cell {
|
|
position: relative;
|
|
}
|
|
|
|
.price-input-wrapper {
|
|
position: relative;
|
|
}
|
|
.currency-symbol {
|
|
position: absolute;
|
|
left: 9px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
color: var(--clr-text-muted);
|
|
font-family: "DM Mono", monospace;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.line-delete-btn {
|
|
width: 32px;
|
|
height: 32px;
|
|
min-width: 32px;
|
|
padding: 0;
|
|
border-radius: var(--radius-sm);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 0.7rem;
|
|
transition: all var(--transition);
|
|
}
|
|
|
|
.line-delete-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────
|
|
LINES FOOTER
|
|
───────────────────────────────────────────── */
|
|
.lines-footer {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 1rem 0 0;
|
|
border-top: 1.5px dashed var(--clr-border);
|
|
margin-top: 0.75rem;
|
|
flex-wrap: wrap;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.btn-add-line {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 7px 14px;
|
|
border: 1.5px dashed var(--clr-primary);
|
|
background: var(--clr-primary-light);
|
|
color: var(--clr-primary);
|
|
border-radius: var(--radius-md);
|
|
font-family: "DM Sans", sans-serif;
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all var(--transition);
|
|
}
|
|
|
|
.btn-add-line:hover {
|
|
background: var(--clr-primary);
|
|
color: #fff;
|
|
border-style: solid;
|
|
}
|
|
|
|
.lines-summary {
|
|
display: flex;
|
|
gap: 1.25rem;
|
|
align-items: center;
|
|
}
|
|
|
|
.summary-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-end;
|
|
gap: 1px;
|
|
}
|
|
|
|
.summary-label {
|
|
font-size: 0.65rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
color: var(--clr-text-muted);
|
|
}
|
|
|
|
.summary-value {
|
|
font-size: 0.9rem;
|
|
font-weight: 700;
|
|
color: var(--clr-text);
|
|
font-family: "DM Mono", monospace;
|
|
}
|
|
|
|
.summary-item--total .summary-value {
|
|
color: var(--clr-primary);
|
|
font-size: 1rem;
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────
|
|
RECAP GRID
|
|
───────────────────────────────────────────── */
|
|
.recap-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr 1fr;
|
|
gap: 1rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
@media (max-width: 640px) {
|
|
.recap-grid {
|
|
grid-template-columns: 1fr 1fr;
|
|
}
|
|
}
|
|
|
|
.recap-item {
|
|
background: var(--clr-surface);
|
|
border: 1.5px solid var(--clr-border);
|
|
border-radius: var(--radius-md);
|
|
padding: 0.9rem 1rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.recap-label {
|
|
font-size: 0.7rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
color: var(--clr-text-muted);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
}
|
|
|
|
.recap-value {
|
|
font-size: 0.9rem;
|
|
font-weight: 600;
|
|
color: var(--clr-text);
|
|
}
|
|
|
|
.recap-total {
|
|
font-size: 1.1rem;
|
|
color: var(--clr-primary);
|
|
font-family: "DM Mono", monospace;
|
|
}
|
|
|
|
.status-pill {
|
|
font-size: 0.72rem;
|
|
font-weight: 700;
|
|
padding: 3px 10px;
|
|
border-radius: 99px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
|
|
.status-pill.draft {
|
|
background: #fff3bf;
|
|
color: #946500;
|
|
}
|
|
|
|
.status-pill.posted {
|
|
background: var(--clr-success-light);
|
|
color: #0b7a5e;
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────
|
|
BUTTONS
|
|
───────────────────────────────────────────── */
|
|
.card-actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
padding-top: 1.25rem;
|
|
border-top: 1.5px solid var(--clr-border);
|
|
margin-top: 1.25rem;
|
|
}
|
|
|
|
.card-actions--spread {
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.action-group {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
align-items: center;
|
|
}
|
|
|
|
.btn-primary,
|
|
.btn-success {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
height: 40px;
|
|
padding: 0 1.25rem;
|
|
border: none;
|
|
border-radius: var(--radius-md);
|
|
font-family: "DM Sans", sans-serif;
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all var(--transition);
|
|
}
|
|
|
|
.btn-primary {
|
|
background: var(--clr-primary);
|
|
color: #fff;
|
|
}
|
|
.btn-primary:hover {
|
|
background: var(--clr-primary-dark);
|
|
transform: translateY(-1px);
|
|
box-shadow: 0 4px 12px rgba(59, 91, 219, 0.3);
|
|
}
|
|
|
|
.btn-success {
|
|
background: var(--clr-success);
|
|
color: #fff;
|
|
}
|
|
.btn-success:hover:not(:disabled) {
|
|
filter: brightness(1.08);
|
|
transform: translateY(-1px);
|
|
box-shadow: 0 4px 12px rgba(18, 184, 134, 0.3);
|
|
}
|
|
.btn-success:disabled {
|
|
opacity: 0.65;
|
|
cursor: not-allowed;
|
|
transform: none;
|
|
}
|
|
|
|
.btn-ghost {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
height: 40px;
|
|
padding: 0 1rem;
|
|
border: 1.5px solid var(--clr-border);
|
|
background: transparent;
|
|
border-radius: var(--radius-md);
|
|
font-family: "DM Sans", sans-serif;
|
|
font-size: 0.85rem;
|
|
font-weight: 500;
|
|
color: var(--clr-text-muted);
|
|
cursor: pointer;
|
|
transition: all var(--transition);
|
|
}
|
|
.btn-ghost:hover {
|
|
border-color: var(--clr-primary);
|
|
color: var(--clr-primary);
|
|
background: var(--clr-primary-light);
|
|
}
|
|
|
|
.btn-outline-danger {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
height: 40px;
|
|
padding: 0 1rem;
|
|
border: 1.5px solid #ffc9c9;
|
|
background: transparent;
|
|
border-radius: var(--radius-md);
|
|
font-family: "DM Sans", sans-serif;
|
|
font-size: 0.85rem;
|
|
font-weight: 500;
|
|
color: var(--clr-danger);
|
|
cursor: pointer;
|
|
transition: all var(--transition);
|
|
}
|
|
.btn-outline-danger:hover {
|
|
background: var(--clr-danger-light);
|
|
border-color: var(--clr-danger);
|
|
}
|
|
|
|
.btn-spinner {
|
|
width: 16px;
|
|
height: 16px;
|
|
border: 2px solid rgba(255, 255, 255, 0.35);
|
|
border-top-color: #fff;
|
|
border-radius: 50%;
|
|
animation: spin 0.6s linear infinite;
|
|
}
|
|
|
|
.soft-action-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.btn-add-line-soft {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────
|
|
TRANSITIONS
|
|
───────────────────────────────────────────── */
|
|
.slide-down-enter-active,
|
|
.slide-down-leave-active {
|
|
transition: all 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
|
overflow: hidden;
|
|
}
|
|
.slide-down-enter-from,
|
|
.slide-down-leave-to {
|
|
opacity: 0;
|
|
max-height: 0;
|
|
padding-top: 0;
|
|
padding-bottom: 0;
|
|
}
|
|
.slide-down-enter-to,
|
|
.slide-down-leave-from {
|
|
max-height: 800px;
|
|
}
|
|
|
|
.dropdown-enter-active,
|
|
.dropdown-leave-active {
|
|
transition: all 0.16s ease;
|
|
}
|
|
.dropdown-enter-from,
|
|
.dropdown-leave-to {
|
|
opacity: 0;
|
|
transform: translateY(-6px);
|
|
}
|
|
|
|
.fade-enter-active,
|
|
.fade-leave-active {
|
|
transition: opacity 0.15s;
|
|
}
|
|
.fade-enter-from,
|
|
.fade-leave-to {
|
|
opacity: 0;
|
|
}
|
|
|
|
.shake-enter-active {
|
|
animation: shake 0.3s ease;
|
|
}
|
|
@keyframes shake {
|
|
0%,
|
|
100% {
|
|
transform: translateX(0);
|
|
}
|
|
25% {
|
|
transform: translateX(-4px);
|
|
}
|
|
75% {
|
|
transform: translateX(4px);
|
|
}
|
|
}
|
|
|
|
.line-list-enter-active {
|
|
transition: all 0.2s ease;
|
|
}
|
|
.line-list-leave-active {
|
|
transition: all 0.15s ease;
|
|
}
|
|
.line-list-enter-from {
|
|
opacity: 0;
|
|
transform: translateY(-8px);
|
|
}
|
|
.line-list-leave-to {
|
|
opacity: 0;
|
|
transform: translateX(20px);
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────
|
|
MISC
|
|
───────────────────────────────────────────── */
|
|
.position-relative {
|
|
position: relative;
|
|
}
|
|
</style>
|