2026-03-24 14:19:49 +03:00

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>