|
@@ -0,0 +1,460 @@
|
|
|
|
|
+<script setup>
|
|
|
|
|
+import { ref, reactive, computed, onMounted } from 'vue'
|
|
|
|
|
+import { useInvoicesStore } from '@/store/invoices.js'
|
|
|
|
|
+import Modal from '@/components/ui/Modal.vue'
|
|
|
|
|
+import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
|
|
|
|
|
+
|
|
|
|
|
+// ── Store ─────────────────────────────────────────────────────────────────
|
|
|
|
|
+const store = useInvoicesStore()
|
|
|
|
|
+
|
|
|
|
|
+// ── Modal / confirm state ─────────────────────────────────────────────────
|
|
|
|
|
+const showModal = ref(false)
|
|
|
|
|
+const showConfirm = ref(false)
|
|
|
|
|
+const editingId = ref(null)
|
|
|
|
|
+const deleteId = ref(null)
|
|
|
|
|
+const saving = ref(false)
|
|
|
|
|
+const formError = ref('')
|
|
|
|
|
+
|
|
|
|
|
+// ── Notification-date tag input ───────────────────────────────────────────
|
|
|
|
|
+const newDayInput = ref('')
|
|
|
|
|
+
|
|
|
|
|
+const EMPTY_FORM = () => ({
|
|
|
|
|
+ invoice_number: '',
|
|
|
|
|
+ client_id: null,
|
|
|
|
|
+ pay_date: '',
|
|
|
|
|
+ template_id: null,
|
|
|
|
|
+ notification_dates: [],
|
|
|
|
|
+ overdue_recurring_days: 0,
|
|
|
|
|
+ paid: false,
|
|
|
|
|
+})
|
|
|
|
|
+const form = reactive(EMPTY_FORM())
|
|
|
|
|
+
|
|
|
|
|
+// ── Derived display helpers ───────────────────────────────────────────────
|
|
|
|
|
+function isOverdue(invoice) {
|
|
|
|
|
+ if (invoice.paid || !invoice.pay_date) return false
|
|
|
|
|
+ return new Date(invoice.pay_date) < new Date(new Date().toDateString())
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function daysLabel(invoice) {
|
|
|
|
|
+ const today = new Date(new Date().toDateString())
|
|
|
|
|
+ const due = new Date(invoice.pay_date)
|
|
|
|
|
+ const diff = Math.round((due - today) / 86400000)
|
|
|
|
|
+ if (invoice.paid) return null
|
|
|
|
|
+ if (diff === 0) return { label: 'Due today', cls: 'badge-warning' }
|
|
|
|
|
+ if (diff > 0) return { label: `In ${diff}d`, cls: 'badge-info' }
|
|
|
|
|
+ return { label: `${-diff}d overdue`, cls: 'badge-danger' }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── CRUD handlers ─────────────────────────────────────────────────────────
|
|
|
|
|
+function openCreate() {
|
|
|
|
|
+ Object.assign(form, EMPTY_FORM())
|
|
|
|
|
+ editingId.value = null
|
|
|
|
|
+ formError.value = ''
|
|
|
|
|
+ newDayInput.value = ''
|
|
|
|
|
+ showModal.value = true
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function openEdit(invoice) {
|
|
|
|
|
+ Object.assign(form, {
|
|
|
|
|
+ invoice_number: invoice.invoice_number,
|
|
|
|
|
+ client_id: invoice.client_id,
|
|
|
|
|
+ pay_date: invoice.pay_date,
|
|
|
|
|
+ template_id: invoice.template_id,
|
|
|
|
|
+ notification_dates: [...(invoice.notification_dates ?? [])],
|
|
|
|
|
+ overdue_recurring_days: invoice.overdue_recurring_days,
|
|
|
|
|
+ paid: invoice.paid,
|
|
|
|
|
+ })
|
|
|
|
|
+ editingId.value = invoice.id
|
|
|
|
|
+ formError.value = ''
|
|
|
|
|
+ newDayInput.value = ''
|
|
|
|
|
+ showModal.value = true
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function confirmDelete(id) {
|
|
|
|
|
+ deleteId.value = id
|
|
|
|
|
+ showConfirm.value = true
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── Notification date chips ───────────────────────────────────────────────
|
|
|
|
|
+function addDay() {
|
|
|
|
|
+ const val = parseInt(newDayInput.value)
|
|
|
|
|
+ if (!isNaN(val) && val > 0 && !form.notification_dates.includes(val)) {
|
|
|
|
|
+ form.notification_dates.push(val)
|
|
|
|
|
+ form.notification_dates.sort((a, b) => a - b)
|
|
|
|
|
+ }
|
|
|
|
|
+ newDayInput.value = ''
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function removeDay(day) {
|
|
|
|
|
+ form.notification_dates = form.notification_dates.filter(d => d !== day)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function onDayKeydown(e) {
|
|
|
|
|
+ if (e.key === 'Enter') { e.preventDefault(); addDay() }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── Save ──────────────────────────────────────────────────────────────────
|
|
|
|
|
+async function save() {
|
|
|
|
|
+ saving.value = true
|
|
|
|
|
+ formError.value = ''
|
|
|
|
|
+ try {
|
|
|
|
|
+ const payload = { ...form }
|
|
|
|
|
+ if (editingId.value) {
|
|
|
|
|
+ await store.update(editingId.value, payload)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ await store.create(payload)
|
|
|
|
|
+ }
|
|
|
|
|
+ showModal.value = false
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ formError.value = e.response?.data?.detail ?? 'Save failed.'
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ saving.value = false
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── Toggle paid ───────────────────────────────────────────────────────────
|
|
|
|
|
+async function togglePaid(invoice) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await store.togglePaid(invoice)
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ store.error = e.response?.data?.detail ?? 'Failed to update paid status.'
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── Delete ────────────────────────────────────────────────────────────────
|
|
|
|
|
+async function remove() {
|
|
|
|
|
+ showConfirm.value = false
|
|
|
|
|
+ try {
|
|
|
|
|
+ await store.remove(deleteId.value)
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ store.error = e.response?.data?.detail ?? 'Delete failed.'
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+onMounted(() => store.fetchAll())
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<template>
|
|
|
|
|
+ <div class="space-y-5 animate-fade-in">
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Header -->
|
|
|
|
|
+ <div class="flex flex-col sm:flex-row sm:items-center gap-4">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <h2 class="text-xl font-bold text-white">Invoices</h2>
|
|
|
|
|
+ <p class="text-sm text-slate-400 mt-0.5">Open receivables with automated notification scheduling</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <button id="invoices-add-btn" @click="openCreate" class="sm:ml-auto btn-primary">
|
|
|
|
|
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
|
|
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ New Invoice
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Stats -->
|
|
|
|
|
+ <div class="grid grid-cols-3 gap-3">
|
|
|
|
|
+ <div class="card px-4 py-3">
|
|
|
|
|
+ <p class="text-xs text-slate-400">Total</p>
|
|
|
|
|
+ <p class="text-2xl font-bold text-white mt-0.5">{{ store.invoices.length }}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="card px-4 py-3">
|
|
|
|
|
+ <p class="text-xs text-slate-400">Open</p>
|
|
|
|
|
+ <p class="text-2xl font-bold text-amber-400 mt-0.5">{{ store.openInvoices.length }}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="card px-4 py-3">
|
|
|
|
|
+ <p class="text-xs text-slate-400">Paid</p>
|
|
|
|
|
+ <p class="text-2xl font-bold text-emerald-400 mt-0.5">{{ store.paidInvoices.length }}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Overdue alert banner -->
|
|
|
|
|
+ <div
|
|
|
|
|
+ v-if="store.overdueInvoices.length > 0"
|
|
|
|
|
+ class="card p-4 flex items-center gap-3 border-red-500/40 bg-red-500/5"
|
|
|
|
|
+ >
|
|
|
|
|
+ <svg class="w-5 h-5 text-red-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
|
|
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ <p class="text-sm text-red-400 font-medium">
|
|
|
|
|
+ {{ store.overdueInvoices.length }} overdue invoice{{ store.overdueInvoices.length !== 1 ? 's' : '' }} — run the cron scan to dispatch overdue notices.
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Error -->
|
|
|
|
|
+ <div v-if="store.error" class="card p-4 flex items-center gap-3 border-red-500/30">
|
|
|
|
|
+ <p class="text-sm text-red-400">{{ store.error }}</p>
|
|
|
|
|
+ <button @click="store.error = ''" class="ml-auto btn-icon text-red-400">
|
|
|
|
|
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
|
|
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Loading -->
|
|
|
|
|
+ <div v-if="store.loading" class="card p-12 flex flex-col items-center gap-3 text-slate-500">
|
|
|
|
|
+ <svg class="w-6 h-6 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
|
|
|
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
|
|
|
|
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ <span class="text-sm">Loading invoices…</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Table -->
|
|
|
|
|
+ <div v-else class="card overflow-hidden">
|
|
|
|
|
+ <div class="overflow-x-auto">
|
|
|
|
|
+ <table class="w-full min-w-[900px]">
|
|
|
|
|
+ <thead class="bg-slate-800/60">
|
|
|
|
|
+ <tr>
|
|
|
|
|
+ <th class="table-header-cell w-16">ID</th>
|
|
|
|
|
+ <th class="table-header-cell">Invoice #</th>
|
|
|
|
|
+ <th class="table-header-cell">Client</th>
|
|
|
|
|
+ <th class="table-header-cell w-32">Due Date</th>
|
|
|
|
|
+ <th class="table-header-cell">Template</th>
|
|
|
|
|
+ <th class="table-header-cell w-28">Notify Days</th>
|
|
|
|
|
+ <th class="table-header-cell w-28 text-center">Status</th>
|
|
|
|
|
+ <th class="table-header-cell w-28 text-center">Paid</th>
|
|
|
|
|
+ <th class="table-header-cell w-24 text-right">Actions</th>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ </thead>
|
|
|
|
|
+ <tbody>
|
|
|
|
|
+ <tr v-if="store.invoices.length === 0">
|
|
|
|
|
+ <td colspan="9" class="px-4 py-12 text-center text-sm text-slate-500">
|
|
|
|
|
+ No invoices yet. Click "New Invoice" to create one.
|
|
|
|
|
+ </td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ <tr
|
|
|
|
|
+ v-for="invoice in store.invoices"
|
|
|
|
|
+ :key="invoice.id"
|
|
|
|
|
+ :class="['table-row', invoice.paid ? 'opacity-50' : '']"
|
|
|
|
|
+ >
|
|
|
|
|
+ <td class="table-cell text-slate-500 font-mono text-xs">{{ invoice.id }}</td>
|
|
|
|
|
+ <td class="table-cell">
|
|
|
|
|
+ <code class="font-mono text-xs text-sky-300 bg-sky-500/10 px-2 py-0.5 rounded">{{ invoice.invoice_number }}</code>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td class="table-cell font-medium text-slate-100">{{ invoice.client?.client_name ?? store.clientName(invoice.client_id) }}</td>
|
|
|
|
|
+ <td class="table-cell text-slate-300 text-xs font-mono whitespace-nowrap">{{ invoice.pay_date }}</td>
|
|
|
|
|
+ <td class="table-cell">
|
|
|
|
|
+ <span class="text-xs text-slate-400">{{ invoice.template?.name ?? store.templateName(invoice.template_id) }}</span>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <!-- Notification days chips -->
|
|
|
|
|
+ <td class="table-cell">
|
|
|
|
|
+ <div class="flex flex-wrap gap-1">
|
|
|
|
|
+ <span
|
|
|
|
|
+ v-for="d in (invoice.notification_dates ?? [])"
|
|
|
|
|
+ :key="d"
|
|
|
|
|
+ class="inline-flex items-center px-1.5 py-0.5 bg-brand-500/15 text-brand-400 rounded text-[10px] font-mono"
|
|
|
|
|
+ >-{{ d }}d</span>
|
|
|
|
|
+ <span v-if="!(invoice.notification_dates ?? []).length" class="text-slate-600 text-xs">—</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <!-- Status badge -->
|
|
|
|
|
+ <td class="table-cell text-center">
|
|
|
|
|
+ <template v-if="invoice.paid">
|
|
|
|
|
+ <span class="badge-success">Paid</span>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ <template v-else>
|
|
|
|
|
+ <span v-if="daysLabel(invoice)" :class="daysLabel(invoice).cls">
|
|
|
|
|
+ {{ daysLabel(invoice).label }}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <!-- Paid toggle -->
|
|
|
|
|
+ <td class="table-cell text-center">
|
|
|
|
|
+ <button
|
|
|
|
|
+ :id="`invoice-paid-toggle-${invoice.id}`"
|
|
|
|
|
+ @click="togglePaid(invoice)"
|
|
|
|
|
+ :class="[
|
|
|
|
|
+ 'relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 focus:ring-offset-slate-900',
|
|
|
|
|
+ invoice.paid ? 'bg-emerald-600' : 'bg-slate-700',
|
|
|
|
|
+ ]"
|
|
|
|
|
+ :title="invoice.paid ? 'Mark as unpaid' : 'Mark as paid'"
|
|
|
|
|
+ >
|
|
|
|
|
+ <span
|
|
|
|
|
+ :class="[
|
|
|
|
|
+ 'inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform duration-200',
|
|
|
|
|
+ invoice.paid ? 'translate-x-[18px]' : 'translate-x-[3px]',
|
|
|
|
|
+ ]"
|
|
|
|
|
+ />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <!-- Actions -->
|
|
|
|
|
+ <td class="table-cell text-right">
|
|
|
|
|
+ <div class="flex items-center justify-end gap-1">
|
|
|
|
|
+ <button :id="`invoice-edit-${invoice.id}`" @click="openEdit(invoice)" class="btn-icon" title="Edit">
|
|
|
|
|
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
|
|
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button :id="`invoice-delete-${invoice.id}`" @click="confirmDelete(invoice.id)" class="btn-icon hover:text-red-400 hover:bg-red-500/10" title="Delete">
|
|
|
|
|
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
|
|
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Create / Edit Modal -->
|
|
|
|
|
+ <Modal
|
|
|
|
|
+ :show="showModal"
|
|
|
|
|
+ :title="editingId ? 'Edit Invoice' : 'New Invoice'"
|
|
|
|
|
+ size="lg"
|
|
|
|
|
+ @close="showModal = false"
|
|
|
|
|
+ >
|
|
|
|
|
+ <form id="invoice-form" @submit.prevent="save" class="space-y-4">
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Row 1: Invoice # + Client -->
|
|
|
|
|
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label class="form-label" for="inv-number">Invoice Number</label>
|
|
|
|
|
+ <input id="inv-number" v-model="form.invoice_number" type="text" required
|
|
|
|
|
+ placeholder="INV-2024-001" class="form-input" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label class="form-label" for="inv-client">Client</label>
|
|
|
|
|
+ <select id="inv-client" v-model="form.client_id" required class="form-select">
|
|
|
|
|
+ <option :value="null" disabled>Select a client…</option>
|
|
|
|
|
+ <option v-for="c in store.clients" :key="c.id" :value="c.id">
|
|
|
|
|
+ {{ c.client_name }} ({{ c.client_id_name }})
|
|
|
|
|
+ </option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Row 2: Pay date + Template -->
|
|
|
|
|
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label class="form-label" for="inv-paydate">Due Date</label>
|
|
|
|
|
+ <input id="inv-paydate" v-model="form.pay_date" type="date" required class="form-input" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label class="form-label" for="inv-template">Email Template</label>
|
|
|
|
|
+ <select id="inv-template" v-model="form.template_id" required class="form-select">
|
|
|
|
|
+ <option :value="null" disabled>Select a template…</option>
|
|
|
|
|
+ <option v-for="t in store.templates" :key="t.id" :value="t.id">
|
|
|
|
|
+ {{ t.name }} ({{ t.template_type }})
|
|
|
|
|
+ </option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Notification dates tag input -->
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label class="form-label">
|
|
|
|
|
+ Notification Days Before Due
|
|
|
|
|
+ <span class="text-slate-600 normal-case font-normal ml-1">— days before the due date to send a reminder</span>
|
|
|
|
|
+ </label>
|
|
|
|
|
+ <!-- Chips display -->
|
|
|
|
|
+ <div class="flex flex-wrap gap-1.5 mb-2 min-h-[28px]">
|
|
|
|
|
+ <span v-if="form.notification_dates.length === 0" class="text-xs text-slate-600 italic">No days added yet</span>
|
|
|
|
|
+ <span
|
|
|
|
|
+ v-for="d in form.notification_dates"
|
|
|
|
|
+ :key="d"
|
|
|
|
|
+ class="chip flex items-center gap-1"
|
|
|
|
|
+ >
|
|
|
|
|
+ {{ d }} day{{ d !== 1 ? 's' : '' }}
|
|
|
|
|
+ <button type="button" @click="removeDay(d)"
|
|
|
|
|
+ class="ml-0.5 hover:text-red-400 transition-colors"
|
|
|
|
|
+ :aria-label="`Remove ${d} days`">
|
|
|
|
|
+ <svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
|
|
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <!-- Input row -->
|
|
|
|
|
+ <div class="flex gap-2">
|
|
|
|
|
+ <input
|
|
|
|
|
+ id="inv-notify-day"
|
|
|
|
|
+ v-model="newDayInput"
|
|
|
|
|
+ type="number"
|
|
|
|
|
+ min="1"
|
|
|
|
|
+ max="365"
|
|
|
|
|
+ placeholder="e.g. 7"
|
|
|
|
|
+ class="form-input w-28"
|
|
|
|
|
+ @keydown="onDayKeydown"
|
|
|
|
|
+ />
|
|
|
|
|
+ <button type="button" @click="addDay" class="btn-ghost flex-shrink-0">
|
|
|
|
|
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
|
|
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ Add Day
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <p class="text-[11px] text-slate-500 mt-1.5">Press Enter or click "Add Day". Common values: 30, 14, 7, 3, 1.</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Overdue interval + Paid toggle -->
|
|
|
|
|
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label class="form-label" for="inv-overdue-days">
|
|
|
|
|
+ Overdue Recurring Interval
|
|
|
|
|
+ <span class="text-slate-600 normal-case font-normal ml-1">(days, 0 = disabled)</span>
|
|
|
|
|
+ </label>
|
|
|
|
|
+ <input id="inv-overdue-days" v-model.number="form.overdue_recurring_days" type="number"
|
|
|
|
|
+ min="0" placeholder="7" class="form-input" />
|
|
|
|
|
+ <p class="text-[11px] text-slate-500 mt-1">Send overdue notices every N days past the due date.</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label class="form-label">Paid Status</label>
|
|
|
|
|
+ <div class="flex items-center gap-3 mt-2">
|
|
|
|
|
+ <button
|
|
|
|
|
+ id="inv-paid-toggle"
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ @click="form.paid = !form.paid"
|
|
|
|
|
+ :class="[
|
|
|
|
|
+ 'relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 focus:ring-offset-slate-900',
|
|
|
|
|
+ form.paid ? 'bg-emerald-600' : 'bg-slate-700',
|
|
|
|
|
+ ]"
|
|
|
|
|
+ >
|
|
|
|
|
+ <span
|
|
|
|
|
+ :class="[
|
|
|
|
|
+ 'inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200',
|
|
|
|
|
+ form.paid ? 'translate-x-[22px]' : 'translate-x-[3px]',
|
|
|
|
|
+ ]"
|
|
|
|
|
+ />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <p class="text-sm font-medium text-slate-200">{{ form.paid ? 'Paid' : 'Unpaid' }}</p>
|
|
|
|
|
+ <p class="text-[11px] text-slate-500">
|
|
|
|
|
+ {{ form.paid ? 'All queue actions are silenced for this invoice.' : 'Active — reminders and overdue notices will fire.' }}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div v-if="formError" class="p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
|
|
|
|
+ <p class="text-sm text-red-400">{{ formError }}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </form>
|
|
|
|
|
+
|
|
|
|
|
+ <template #footer>
|
|
|
|
|
+ <button @click="showModal = false" class="btn-ghost">Cancel</button>
|
|
|
|
|
+ <button id="invoice-save-btn" form="invoice-form" type="submit" :disabled="saving" class="btn-primary">
|
|
|
|
|
+ <svg v-if="saving" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
|
|
|
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
|
|
|
|
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ {{ editingId ? 'Save Changes' : 'Create Invoice' }}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </Modal>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Delete Confirmation -->
|
|
|
|
|
+ <ConfirmDialog
|
|
|
|
|
+ :show="showConfirm"
|
|
|
|
|
+ title="Delete Invoice"
|
|
|
|
|
+ message="This will permanently remove the invoice and all its notification log entries."
|
|
|
|
|
+ confirm-label="Delete Invoice"
|
|
|
|
|
+ :danger="true"
|
|
|
|
|
+ @confirm="remove"
|
|
|
|
|
+ @cancel="showConfirm = false"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+</template>
|