feat(admin): add configurable order views (#13211)
Adds support for configurable order views. https://github.com/user-attachments/assets/ed4a5f61-1667-4ed7-9478-423894f3eba6
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
import React from "react"
|
||||
import { Badge, StatusBadge, Tooltip } from "@medusajs/ui"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import ReactCountryFlag from "react-country-flag"
|
||||
import { getCountryByIso2 } from "./data/countries"
|
||||
import { getStylizedAmount } from "./money-amount-helpers"
|
||||
|
||||
// Helper function to get nested value from object using dot notation
|
||||
const getNestedValue = (obj: any, path: string) => {
|
||||
return path.split('.').reduce((current, key) => current?.[key], obj)
|
||||
}
|
||||
|
||||
// Helper function to format date
|
||||
const formatDate = (date: string | Date, format: 'short' | 'long' | 'relative' = 'short') => {
|
||||
const dateObj = new Date(date)
|
||||
|
||||
switch (format) {
|
||||
case 'short':
|
||||
return dateObj.toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
})
|
||||
case 'long':
|
||||
return dateObj.toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
case 'relative':
|
||||
const now = new Date()
|
||||
const diffInMs = now.getTime() - dateObj.getTime()
|
||||
const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffInDays === 0) return 'Today'
|
||||
if (diffInDays === 1) return 'Yesterday'
|
||||
if (diffInDays < 7) return `${diffInDays} days ago`
|
||||
|
||||
return dateObj.toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short'
|
||||
})
|
||||
default:
|
||||
return dateObj.toLocaleDateString()
|
||||
}
|
||||
}
|
||||
|
||||
// Payment status display
|
||||
const PaymentStatusBadge = ({ status }: { status: string }) => {
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status?.toLowerCase()) {
|
||||
case 'paid':
|
||||
case 'captured':
|
||||
return 'green'
|
||||
case 'pending':
|
||||
case 'awaiting':
|
||||
return 'orange'
|
||||
case 'failed':
|
||||
case 'canceled':
|
||||
return 'red'
|
||||
default:
|
||||
return 'grey'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusBadge color={getStatusColor(status)}>
|
||||
{status}
|
||||
</StatusBadge>
|
||||
)
|
||||
}
|
||||
|
||||
// Fulfillment status display
|
||||
const FulfillmentStatusBadge = ({ status }: { status: string }) => {
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status?.toLowerCase()) {
|
||||
case 'fulfilled':
|
||||
case 'shipped':
|
||||
return 'green'
|
||||
case 'partially_fulfilled':
|
||||
case 'preparing':
|
||||
return 'orange'
|
||||
case 'canceled':
|
||||
case 'returned':
|
||||
return 'red'
|
||||
case 'pending':
|
||||
case 'not_fulfilled':
|
||||
return 'grey'
|
||||
default:
|
||||
return 'grey'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusBadge color={getStatusColor(status)}>
|
||||
{status}
|
||||
</StatusBadge>
|
||||
)
|
||||
}
|
||||
|
||||
// Generic status badge
|
||||
const GenericStatusBadge = ({ status }: { status: string }) => {
|
||||
return (
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
// Display strategies registry
|
||||
export const DISPLAY_STRATEGIES = {
|
||||
// Known semantic types with pixel-perfect display
|
||||
status: {
|
||||
payment: (value: any) => <PaymentStatusBadge status={value} />,
|
||||
fulfillment: (value: any) => <FulfillmentStatusBadge status={value} />,
|
||||
default: (value: any) => <GenericStatusBadge status={value} />
|
||||
},
|
||||
|
||||
currency: {
|
||||
default: (value: any, row: any) => {
|
||||
if (value === null || value === undefined) return '-'
|
||||
const currencyCode = row.currency_code || 'USD'
|
||||
const formatted = getStylizedAmount(value, currencyCode)
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-end text-right">
|
||||
<span className="truncate">{formatted}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
timestamp: {
|
||||
creation: (value: any) => value ? formatDate(value, 'short') : '-',
|
||||
update: (value: any) => value ? formatDate(value, 'relative') : '-',
|
||||
default: (value: any) => value ? formatDate(value, 'short') : '-'
|
||||
},
|
||||
|
||||
identifier: {
|
||||
order: (value: any) => `#${value}`,
|
||||
default: (value: any) => value
|
||||
},
|
||||
|
||||
email: {
|
||||
default: (value: any) => value || '-'
|
||||
},
|
||||
|
||||
// Generic fallbacks for custom fields
|
||||
enum: {
|
||||
default: (value: any) => <GenericStatusBadge status={value} />
|
||||
},
|
||||
|
||||
// Base type fallbacks
|
||||
string: {
|
||||
default: (value: any) => value || '-'
|
||||
},
|
||||
|
||||
number: {
|
||||
default: (value: any) => value?.toLocaleString() || '0'
|
||||
},
|
||||
|
||||
boolean: {
|
||||
default: (value: any) => (
|
||||
<Badge variant={value ? 'solid' : 'outline'}>
|
||||
{value ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
|
||||
object: {
|
||||
relationship: (value: any) => {
|
||||
if (!value || typeof value !== 'object') return '-'
|
||||
|
||||
// Try common display fields
|
||||
if (value.name) return value.name
|
||||
if (value.title) return value.title
|
||||
if (value.email) return value.email
|
||||
if (value.display_name) return value.display_name
|
||||
|
||||
return JSON.stringify(value)
|
||||
},
|
||||
default: (value: any) => {
|
||||
if (!value || typeof value !== 'object') return '-'
|
||||
|
||||
// Try common display fields
|
||||
if (value.name) return value.name
|
||||
if (value.title) return value.title
|
||||
if (value.email) return value.email
|
||||
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
},
|
||||
|
||||
// Date types (in addition to timestamp)
|
||||
date: {
|
||||
default: (value: any) => value ? formatDate(value, 'short') : '-'
|
||||
},
|
||||
|
||||
datetime: {
|
||||
default: (value: any) => value ? formatDate(value, 'long') : '-'
|
||||
},
|
||||
|
||||
// Computed columns
|
||||
computed: {
|
||||
display: (value: any) => value || '-',
|
||||
default: (value: any) => value || '-'
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy selection function
|
||||
export const getDisplayStrategy = (column: any) => {
|
||||
const semanticStrategies = DISPLAY_STRATEGIES[column.semantic_type as keyof typeof DISPLAY_STRATEGIES]
|
||||
if (semanticStrategies) {
|
||||
const contextStrategy = semanticStrategies[column.context as keyof typeof semanticStrategies]
|
||||
if (contextStrategy) return contextStrategy
|
||||
|
||||
const defaultStrategy = semanticStrategies.default
|
||||
if (defaultStrategy) return defaultStrategy
|
||||
}
|
||||
|
||||
// Fallback to data type
|
||||
// Map 'text' data type to 'string' strategy
|
||||
const dataType = column.data_type === 'text' ? 'string' : column.data_type
|
||||
const dataTypeStrategies = DISPLAY_STRATEGIES[dataType as keyof typeof DISPLAY_STRATEGIES]
|
||||
if (dataTypeStrategies) {
|
||||
const defaultStrategy = dataTypeStrategies.default
|
||||
if (defaultStrategy) return defaultStrategy
|
||||
}
|
||||
|
||||
// Final fallback
|
||||
return (value: any) => String(value || '-')
|
||||
}
|
||||
|
||||
// Computed column computation functions
|
||||
export const COMPUTED_COLUMN_FUNCTIONS = {
|
||||
customer_name: (row: any) => {
|
||||
// Try customer object first
|
||||
if (row.customer?.first_name || row.customer?.last_name) {
|
||||
const fullName = `${row.customer.first_name || ''} ${row.customer.last_name || ''}`.trim()
|
||||
if (fullName) return fullName
|
||||
}
|
||||
|
||||
// Fall back to email
|
||||
if (row.customer?.email) {
|
||||
return row.customer.email
|
||||
}
|
||||
|
||||
// Fall back to phone
|
||||
if (row.customer?.phone) {
|
||||
return row.customer.phone
|
||||
}
|
||||
|
||||
return 'Guest'
|
||||
},
|
||||
|
||||
address_summary: (row: any, column?: any) => {
|
||||
// Determine which address to use based on the column field
|
||||
let address = null
|
||||
if (column?.field === 'shipping_address_display') {
|
||||
address = row.shipping_address
|
||||
} else if (column?.field === 'billing_address_display') {
|
||||
address = row.billing_address
|
||||
} else {
|
||||
// Fallback to shipping address if no specific field
|
||||
address = row.shipping_address || row.billing_address
|
||||
}
|
||||
|
||||
if (!address) return '-'
|
||||
|
||||
// Build address parts in a meaningful order
|
||||
const parts = []
|
||||
|
||||
// Include street address if available
|
||||
if (address.address_1) {
|
||||
parts.push(address.address_1)
|
||||
}
|
||||
|
||||
// City, Province/State, Postal Code
|
||||
const locationParts = []
|
||||
if (address.city) locationParts.push(address.city)
|
||||
if (address.province) locationParts.push(address.province)
|
||||
if (address.postal_code) locationParts.push(address.postal_code)
|
||||
|
||||
if (locationParts.length > 0) {
|
||||
parts.push(locationParts.join(', '))
|
||||
}
|
||||
|
||||
// Country
|
||||
if (address.country_code) {
|
||||
parts.push(address.country_code.toUpperCase())
|
||||
}
|
||||
|
||||
return parts.join(' • ') || '-'
|
||||
},
|
||||
|
||||
country_code: (row: any) => {
|
||||
// Get country code from shipping address
|
||||
const countryCode = row.shipping_address?.country_code
|
||||
|
||||
if (!countryCode) return <div className="flex w-full justify-center">-</div>
|
||||
|
||||
// Get country information
|
||||
const country = getCountryByIso2(countryCode)
|
||||
const displayName = country?.display_name || countryCode.toUpperCase()
|
||||
|
||||
// Display country flag with tooltip - centered in the cell
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<Tooltip content={displayName}>
|
||||
<div className="flex size-4 items-center justify-center overflow-hidden rounded-sm">
|
||||
<ReactCountryFlag
|
||||
countryCode={countryCode.toUpperCase()}
|
||||
svg
|
||||
style={{
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
}}
|
||||
aria-label={displayName}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Entity-specific column overrides
|
||||
export const ENTITY_COLUMN_OVERRIDES = {
|
||||
orders: {
|
||||
// Override for customer column that combines multiple fields
|
||||
customer: {
|
||||
accessor: (row: any) => {
|
||||
// Complex logic for combining fields
|
||||
const shipping = row.shipping_address
|
||||
const customer = row.customer
|
||||
|
||||
if (shipping?.first_name || shipping?.last_name) {
|
||||
return `${shipping.first_name || ''} ${shipping.last_name || ''}`.trim()
|
||||
}
|
||||
if (customer?.first_name || customer?.last_name) {
|
||||
return `${customer.first_name || ''} ${customer.last_name || ''}`.trim()
|
||||
}
|
||||
return customer?.email || 'Guest'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get entity-specific accessor
|
||||
export const getEntityAccessor = (entity: string, fieldName: string, column?: any) => {
|
||||
// Check if this is a computed column
|
||||
if (column?.computed) {
|
||||
const computationFn = COMPUTED_COLUMN_FUNCTIONS[column.computed.type as keyof typeof COMPUTED_COLUMN_FUNCTIONS]
|
||||
if (computationFn) {
|
||||
// Return a wrapper function that passes the column info
|
||||
return (row: any) => computationFn(row, column)
|
||||
}
|
||||
}
|
||||
|
||||
const entityOverrides = ENTITY_COLUMN_OVERRIDES[entity as keyof typeof ENTITY_COLUMN_OVERRIDES]
|
||||
if (entityOverrides) {
|
||||
const fieldOverride = entityOverrides[fieldName as keyof typeof entityOverrides]
|
||||
if (fieldOverride?.accessor) {
|
||||
return fieldOverride.accessor
|
||||
}
|
||||
}
|
||||
|
||||
// Default accessor using dot notation
|
||||
return (row: any) => getNestedValue(row, fieldName)
|
||||
}
|
||||
Reference in New Issue
Block a user