mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
Harden invoice public links and tighten draft UI.
Use publicId in links, compact key-point/account labels, English business names, and readable favicon deploy perms. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
cbffb23ec3
commit
2e40d5eb4c
Executable → Regular
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
@@ -11,6 +11,12 @@ import {
|
|||||||
import tableStyles from '../pages/BusinessesPage.module.css'
|
import tableStyles from '../pages/BusinessesPage.module.css'
|
||||||
import styles from '../pages/BusinessInvoicesPage.module.css'
|
import styles from '../pages/BusinessInvoicesPage.module.css'
|
||||||
|
|
||||||
|
function focusKeyPointInput(index: number) {
|
||||||
|
const el = document.querySelector<HTMLInputElement>(`input[data-keypoint-index="${index}"]`)
|
||||||
|
el?.focus()
|
||||||
|
el?.select()
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
itemTemplates: InvoiceItemTemplate[]
|
itemTemplates: InvoiceItemTemplate[]
|
||||||
items: DraftLineItem[]
|
items: DraftLineItem[]
|
||||||
@@ -192,9 +198,9 @@ export function InvoiceDraftFields({
|
|||||||
<div className={styles.repeatStack}>
|
<div className={styles.repeatStack}>
|
||||||
{keyPoints.map((point, index) => (
|
{keyPoints.map((point, index) => (
|
||||||
<div key={point.key} className={styles.repeatRow}>
|
<div key={point.key} className={styles.repeatRow}>
|
||||||
<div className={`${tableStyles.field} ${styles.flexGrow}`}>
|
<div className={`${tableStyles.field} ${styles.flexGrow} ${styles.fieldNoLabel}`}>
|
||||||
<label>Point {index + 1}</label>
|
|
||||||
<input
|
<input
|
||||||
|
data-keypoint-index={index}
|
||||||
value={point.text}
|
value={point.text}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onKeyPointsChange(
|
onKeyPointsChange(
|
||||||
@@ -203,7 +209,19 @@ export function InvoiceDraftFields({
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== 'Enter') return
|
||||||
|
e.preventDefault()
|
||||||
|
const next = index + 1
|
||||||
|
if (next < keyPoints.length) {
|
||||||
|
focusKeyPointInput(next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onKeyPointsChange([...keyPoints, emptyDraftKeyPoint()])
|
||||||
|
window.setTimeout(() => focusKeyPointInput(next), 0)
|
||||||
|
}}
|
||||||
placeholder="e.g. Payment due within 7 days"
|
placeholder="e.g. Payment due within 7 days"
|
||||||
|
aria-label={`Key point ${index + 1}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -237,75 +255,85 @@ export function InvoiceDraftFields({
|
|||||||
<p className={styles.templateHint}>No bank accounts yet.</p>
|
<p className={styles.templateHint}>No bank accounts yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.repeatStack}>
|
<div className={styles.repeatStack}>
|
||||||
{accounts.map((acc) => (
|
{accounts.map((acc, index) => {
|
||||||
<div key={acc.key} className={styles.accountRow}>
|
const showLabels = index === 0
|
||||||
<div className={`${tableStyles.field} ${styles.accountCol2}`}>
|
return (
|
||||||
<label>Bank name</label>
|
<div
|
||||||
<input
|
key={acc.key}
|
||||||
value={acc.bankName}
|
className={`${styles.accountRow} ${showLabels ? '' : styles.accountRowPlain}`}
|
||||||
onChange={(e) =>
|
|
||||||
onAccountsChange(
|
|
||||||
accounts.map((a) =>
|
|
||||||
a.key === acc.key ? { ...a, bankName: e.target.value } : a,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
placeholder="Bank name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={`${tableStyles.field} ${styles.accountCol2}`}>
|
|
||||||
<label>Account holder</label>
|
|
||||||
<input
|
|
||||||
value={acc.accountHolderName}
|
|
||||||
onChange={(e) =>
|
|
||||||
onAccountsChange(
|
|
||||||
accounts.map((a) =>
|
|
||||||
a.key === acc.key ? { ...a, accountHolderName: e.target.value } : a,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
placeholder="Account holder name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={`${tableStyles.field} ${styles.accountCol3}`}>
|
|
||||||
<label>Card number</label>
|
|
||||||
<input
|
|
||||||
value={acc.cardNumber}
|
|
||||||
onChange={(e) =>
|
|
||||||
onAccountsChange(
|
|
||||||
accounts.map((a) =>
|
|
||||||
a.key === acc.key ? { ...a, cardNumber: e.target.value } : a,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
placeholder="Optional"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={`${tableStyles.field} ${styles.accountCol5}`}>
|
|
||||||
<label>IBAN</label>
|
|
||||||
<input
|
|
||||||
value={acc.iban}
|
|
||||||
onChange={(e) =>
|
|
||||||
onAccountsChange(
|
|
||||||
accounts.map((a) =>
|
|
||||||
a.key === acc.key ? { ...a, iban: e.target.value } : a,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
placeholder="Optional"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.removeFieldBtn}
|
|
||||||
onClick={() => onAccountsChange(accounts.filter((a) => a.key !== acc.key))}
|
|
||||||
aria-label="Remove account"
|
|
||||||
title="Remove"
|
|
||||||
>
|
>
|
||||||
<X size={16} />
|
<div className={`${tableStyles.field} ${styles.accountCol2}`}>
|
||||||
</button>
|
{showLabels ? <label>Bank name</label> : null}
|
||||||
</div>
|
<input
|
||||||
))}
|
value={acc.bankName}
|
||||||
|
onChange={(e) =>
|
||||||
|
onAccountsChange(
|
||||||
|
accounts.map((a) =>
|
||||||
|
a.key === acc.key ? { ...a, bankName: e.target.value } : a,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="Bank name"
|
||||||
|
aria-label="Bank name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={`${tableStyles.field} ${styles.accountCol2}`}>
|
||||||
|
{showLabels ? <label>Account holder</label> : null}
|
||||||
|
<input
|
||||||
|
value={acc.accountHolderName}
|
||||||
|
onChange={(e) =>
|
||||||
|
onAccountsChange(
|
||||||
|
accounts.map((a) =>
|
||||||
|
a.key === acc.key ? { ...a, accountHolderName: e.target.value } : a,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="Account holder name"
|
||||||
|
aria-label="Account holder"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={`${tableStyles.field} ${styles.accountCol3}`}>
|
||||||
|
{showLabels ? <label>Card number</label> : null}
|
||||||
|
<input
|
||||||
|
value={acc.cardNumber}
|
||||||
|
onChange={(e) =>
|
||||||
|
onAccountsChange(
|
||||||
|
accounts.map((a) =>
|
||||||
|
a.key === acc.key ? { ...a, cardNumber: e.target.value } : a,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="Optional"
|
||||||
|
aria-label="Card number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={`${tableStyles.field} ${styles.accountCol5}`}>
|
||||||
|
{showLabels ? <label>IBAN</label> : null}
|
||||||
|
<input
|
||||||
|
value={acc.iban}
|
||||||
|
onChange={(e) =>
|
||||||
|
onAccountsChange(
|
||||||
|
accounts.map((a) =>
|
||||||
|
a.key === acc.key ? { ...a, iban: e.target.value } : a,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="Optional"
|
||||||
|
aria-label="IBAN"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.removeFieldBtn}
|
||||||
|
onClick={() => onAccountsChange(accounts.filter((a) => a.key !== acc.key))}
|
||||||
|
aria-label="Remove account"
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,20 +11,20 @@ export function isAllowedAdminHost(hostname = window.location.hostname): boolean
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Public invoice URL for platform invoices.
|
* Public invoice URL for platform invoices.
|
||||||
* Local/dev: current origin (`https://meshkee.app:5174/invoices/{id}`) so the show page is reachable.
|
* Local/dev: current origin (`https://meshkee.app:5174/invoices/{publicId}`) so the show page is reachable.
|
||||||
* Production: `https://{VITE_INVOICE_PUBLIC_DOMAIN}/invoices/{id}` (default meshkee.com).
|
* Production: `https://{VITE_INVOICE_PUBLIC_DOMAIN}/invoices/{publicId}` (default meshkee.com).
|
||||||
* Override either with `VITE_INVOICE_PUBLIC_BASE_URL` (full origin, optional path prefix).
|
* Override either with `VITE_INVOICE_PUBLIC_BASE_URL` (full origin, optional path prefix).
|
||||||
*/
|
*/
|
||||||
export function getPlatformInvoicePublicUrl(invoiceId: string): string {
|
export function getPlatformInvoicePublicUrl(publicId: string): string {
|
||||||
const baseOverride = import.meta.env.VITE_INVOICE_PUBLIC_BASE_URL?.trim()
|
const baseOverride = import.meta.env.VITE_INVOICE_PUBLIC_BASE_URL?.trim()
|
||||||
if (baseOverride) {
|
if (baseOverride) {
|
||||||
return `${baseOverride.replace(/\/$/, '')}/invoices/${invoiceId}`
|
return `${baseOverride.replace(/\/$/, '')}/invoices/${publicId}`
|
||||||
}
|
}
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
return `${window.location.origin}/invoices/${invoiceId}`
|
return `${window.location.origin}/invoices/${publicId}`
|
||||||
}
|
}
|
||||||
const domain = import.meta.env.VITE_INVOICE_PUBLIC_DOMAIN?.trim() || 'meshkee.com'
|
const domain = import.meta.env.VITE_INVOICE_PUBLIC_DOMAIN?.trim() || 'meshkee.com'
|
||||||
return `https://${domain}/invoices/${invoiceId}`
|
return `https://${domain}/invoices/${publicId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Marketing / main business site for platform invoices (default https://meshkee.com). */
|
/** Marketing / main business site for platform invoices (default https://meshkee.com). */
|
||||||
|
|||||||
@@ -138,7 +138,6 @@
|
|||||||
background: rgba(239, 68, 68, 0.08);
|
background: rgba(239, 68, 68, 0.08);
|
||||||
border: 1px solid rgba(239, 68, 68, 0.18);
|
border: 1px solid rgba(239, 68, 68, 0.18);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
align-self: flex-end;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.removeFieldBtn:hover:not(:disabled) {
|
.removeFieldBtn:hover:not(:disabled) {
|
||||||
@@ -147,7 +146,7 @@
|
|||||||
|
|
||||||
.repeatRow {
|
.repeatRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +155,14 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fieldNoLabel {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldNoLabel label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.itemGrid {
|
.itemGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(160px, 2fr) minmax(90px, 1fr) minmax(90px, 1fr) minmax(110px, 1.1fr) minmax(120px, 1.2fr);
|
grid-template-columns: minmax(160px, 2fr) minmax(90px, 1fr) minmax(90px, 1fr) minmax(110px, 1.1fr) minmax(120px, 1.2fr);
|
||||||
@@ -406,6 +413,10 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.accountRowPlain {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.accountCol2,
|
.accountCol2,
|
||||||
.accountCol3,
|
.accountCol3,
|
||||||
.accountCol5 {
|
.accountCol5 {
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export function BusinessInvoicesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function copyPublicLink(invoice: Invoice) {
|
async function copyPublicLink(invoice: Invoice) {
|
||||||
const url = invoice.publicUrl || getPlatformInvoicePublicUrl(invoice.id)
|
const url = invoice.publicUrl || getPlatformInvoicePublicUrl(invoice.publicId)
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(url)
|
await navigator.clipboard.writeText(url)
|
||||||
showToast('Invoice link copied.', 'success')
|
showToast('Invoice link copied.', 'success')
|
||||||
@@ -123,12 +123,12 @@ export function BusinessInvoicesPage() {
|
|||||||
function invoicePublicUrl(invoice: Invoice) {
|
function invoicePublicUrl(invoice: Invoice) {
|
||||||
// Prefer local/dev origin so the public show page is reachable while designing.
|
// Prefer local/dev origin so the public show page is reachable while designing.
|
||||||
if (import.meta.env.DEV || import.meta.env.VITE_INVOICE_PUBLIC_BASE_URL) {
|
if (import.meta.env.DEV || import.meta.env.VITE_INVOICE_PUBLIC_BASE_URL) {
|
||||||
return getPlatformInvoicePublicUrl(invoice.id)
|
return getPlatformInvoicePublicUrl(invoice.publicId)
|
||||||
}
|
}
|
||||||
return invoice.publicUrl || getPlatformInvoicePublicUrl(invoice.id)
|
return invoice.publicUrl || getPlatformInvoicePublicUrl(invoice.publicId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const businessName = business?.nameFa || business?.name || 'Business'
|
const businessName = business?.name || business?.nameFa || 'Business'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={pageStyles.content}>
|
<main className={pageStyles.content}>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function IssueInvoicePage() {
|
|||||||
const [formError, setFormError] = useState('')
|
const [formError, setFormError] = useState('')
|
||||||
|
|
||||||
const listPath = `/businesses/${businessId}/invoices`
|
const listPath = `/businesses/${businessId}/invoices`
|
||||||
const businessName = business?.nameFa || business?.name || 'Business'
|
const businessName = business?.name || business?.nameFa || 'Business'
|
||||||
|
|
||||||
const createTotal = useMemo(() => {
|
const createTotal = useMemo(() => {
|
||||||
return draftItems.reduce((sum, item) => {
|
return draftItems.reduce((sum, item) => {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function PublicInvoicePage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!invoice) return
|
if (!invoice) return
|
||||||
const previous = document.title
|
const previous = document.title
|
||||||
document.title = invoice.name?.trim() || `Invoice #${invoice.id}`
|
document.title = invoice.name?.trim() || `Invoice ${invoice.publicId}`
|
||||||
return () => {
|
return () => {
|
||||||
document.title = previous
|
document.title = previous
|
||||||
}
|
}
|
||||||
@@ -65,7 +65,7 @@ export function PublicInvoicePage() {
|
|||||||
{invoice ? (
|
{invoice ? (
|
||||||
<>
|
<>
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<h1 className={styles.title}>{invoice.name || `Invoice #${invoice.id}`}</h1>
|
<h1 className={styles.title}>{invoice.name || `Invoice ${invoice.publicId}`}</h1>
|
||||||
<p className={styles.meta}>
|
<p className={styles.meta}>
|
||||||
Issued {formatDate(invoice.issuedAt)}
|
Issued {formatDate(invoice.issuedAt)}
|
||||||
{invoice.business?.name ? ` · ${invoice.business.name}` : ''}
|
{invoice.business?.name ? ` · ${invoice.business.name}` : ''}
|
||||||
|
|||||||
@@ -141,9 +141,9 @@ export function deleteBusinessInvoice(businessId: string, invoiceId: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Public show-page payload (no auth). */
|
/** Public show-page payload (no auth). Lookup by opaque publicId. */
|
||||||
export function getPublicInvoice(invoiceId: string, signal?: AbortSignal) {
|
export function getPublicInvoice(publicId: string, signal?: AbortSignal) {
|
||||||
return apiRequest<PublicInvoice>(`/public/invoices/${invoiceId}`, {
|
return apiRequest<PublicInvoice>(`/public/invoices/${publicId}`, {
|
||||||
auth: false,
|
auth: false,
|
||||||
signal,
|
signal,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export interface InvoiceAccount {
|
|||||||
|
|
||||||
export interface Invoice {
|
export interface Invoice {
|
||||||
id: string
|
id: string
|
||||||
|
publicId: string
|
||||||
businessId: string
|
businessId: string
|
||||||
ownerScope: 'platform' | 'business'
|
ownerScope: 'platform' | 'business'
|
||||||
issuerBusinessId: string | null
|
issuerBusinessId: string | null
|
||||||
@@ -76,22 +77,21 @@ export interface Invoice {
|
|||||||
total?: number
|
total?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Public viewer payload (no notes / issuer). */
|
/** Public viewer payload (no notes / issuer / sequential id). */
|
||||||
export type PublicInvoice = Pick<
|
export type PublicInvoice = {
|
||||||
Invoice,
|
publicId: string
|
||||||
| 'id'
|
status: InvoiceStatus
|
||||||
| 'status'
|
name: string | null
|
||||||
| 'name'
|
topText: string | null
|
||||||
| 'topText'
|
issuedAt: string
|
||||||
| 'issuedAt'
|
business?: Invoice['business']
|
||||||
| 'business'
|
items?: InvoiceItem[]
|
||||||
| 'items'
|
keyPoints?: InvoiceKeyPoint[]
|
||||||
| 'keyPoints'
|
accounts?: InvoiceAccount[]
|
||||||
| 'accounts'
|
subtotal?: number
|
||||||
| 'subtotal'
|
total?: number
|
||||||
| 'total'
|
publicUrl: string | null
|
||||||
| 'publicUrl'
|
}
|
||||||
>
|
|
||||||
|
|
||||||
export interface InvoiceItemInput {
|
export interface InvoiceItemInput {
|
||||||
templateId?: string
|
templateId?: string
|
||||||
|
|||||||
+5
-1
@@ -33,9 +33,13 @@ rsync -az --delete \
|
|||||||
ssh root@45.149.76.52 'cd /opt/meshkee/dashboards && npm ci && npm run build && \
|
ssh root@45.149.76.52 'cd /opt/meshkee/dashboards && npm ci && npm run build && \
|
||||||
rsync -a --delete apps/super-admin/dist/ /var/www/meshkee/super-admin/ && \
|
rsync -a --delete apps/super-admin/dist/ /var/www/meshkee/super-admin/ && \
|
||||||
rsync -a --delete apps/business/dist/ /var/www/meshkee/business/ && \
|
rsync -a --delete apps/business/dist/ /var/www/meshkee/business/ && \
|
||||||
rsync -a --delete apps/customer/dist/ /var/www/meshkee/customer/'
|
rsync -a --delete apps/customer/dist/ /var/www/meshkee/customer/ && \
|
||||||
|
find /var/www/meshkee -type f -exec chmod a+r {} + && \
|
||||||
|
find /var/www/meshkee -type d -exec chmod a+rx {} +'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Note:** Source files like `favicon.png` must be world-readable (`644`). If they are `700`, Nginx returns **403** and browsers fall back to a default icon (often the Vite lightning bolt).
|
||||||
|
|
||||||
Build env on server (`apps/*/.env`):
|
Build env on server (`apps/*/.env`):
|
||||||
|
|
||||||
- All: `VITE_API_BASE_URL=https://api.meshkee.com/api/v1`
|
- All: `VITE_API_BASE_URL=https://api.meshkee.com/api/v1`
|
||||||
|
|||||||
@@ -318,6 +318,7 @@ Run in order from `MeshkeeApp Backend/database/migrations/`:
|
|||||||
| `037_invoice_name.sql` | Optional invoice name |
|
| `037_invoice_name.sql` | Optional invoice name |
|
||||||
| `038_invoice_templates.sql` | Full invoice templates + key points / accounts |
|
| `038_invoice_templates.sql` | Full invoice templates + key points / accounts |
|
||||||
| `039_invoice_account_holder.sql` | Account holder name on bank accounts |
|
| `039_invoice_account_holder.sql` | Account holder name on bank accounts |
|
||||||
|
| `040_invoice_public_id.sql` | Opaque 12-digit `public_id` for public links |
|
||||||
|
|
||||||
After schema changes: `npx prisma generate` and restart the backend.
|
After schema changes: `npx prisma generate` and restart the backend.
|
||||||
|
|
||||||
@@ -345,11 +346,12 @@ Two template layers + issued invoices:
|
|||||||
|
|
||||||
**Public invoice viewer (platform):**
|
**Public invoice viewer (platform):**
|
||||||
- Route: super-admin SPA `/invoices/:id` (`PublicInvoicePage`) — glass layout, print-to-PDF, “Issued by” Meshkee footer
|
- Route: super-admin SPA `/invoices/:id` (`PublicInvoicePage`) — glass layout, print-to-PDF, “Issued by” Meshkee footer
|
||||||
- Local/dev link: current Vite origin (e.g. `https://meshkee.app:5174/invoices/{id}`)
|
- Links use opaque **12-digit `publicId`** (not sequential PK) — `GET /public/invoices/:publicId`
|
||||||
|
- Local/dev link: current Vite origin (e.g. `https://meshkee.app:5174/invoices/{publicId}`)
|
||||||
- Production link domain: `VITE_INVOICE_PUBLIC_DOMAIN` / `INVOICE_PUBLIC_DOMAIN` (default `meshkee.com`) — optional full origin override via `*_PUBLIC_BASE_URL`
|
- Production link domain: `VITE_INVOICE_PUBLIC_DOMAIN` / `INVOICE_PUBLIC_DOMAIN` (default `meshkee.com`) — optional full origin override via `*_PUBLIC_BASE_URL`
|
||||||
- Until `meshkee.com` proxies or hosts `/invoices/*`, production links may need that DNS/nginx wiring (viewer code ships with super-admin build)
|
- Until `meshkee.com` proxies or hosts `/invoices/*`, production links may need that DNS/nginx wiring (viewer code ships with super-admin build)
|
||||||
|
|
||||||
**Migrations:** `036_invoices.sql`, `037_invoice_name.sql`, `038_invoice_templates.sql`, `039_invoice_account_holder.sql`
|
**Migrations:** `036_invoices.sql` … `040_invoice_public_id.sql`
|
||||||
|
|
||||||
**Super Admin UI:**
|
**Super Admin UI:**
|
||||||
- `/settings` — Invoice templates list + item templates (top text preview = one-line ellipsis)
|
- `/settings` — Invoice templates list + item templates (top text preview = one-line ellipsis)
|
||||||
|
|||||||
Reference in New Issue
Block a user