touchbistro: Add 15 React MCP apps
This commit is contained in:
parent
14f651082c
commit
6741068aef
177
servers/touchbistro/src/ui/react-app/inventory-tracker/App.tsx
Normal file
177
servers/touchbistro/src/ui/react-app/inventory-tracker/App.tsx
Normal file
@ -0,0 +1,177 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import './styles.css';
|
||||
|
||||
interface InventoryItem {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
currentStock: number;
|
||||
parLevel: number;
|
||||
unit: string;
|
||||
costPerUnit: number;
|
||||
supplier: string;
|
||||
lastRestocked: string;
|
||||
}
|
||||
|
||||
export default function InventoryTracker() {
|
||||
const [items, setItems] = useState<InventoryItem[]>([]);
|
||||
const [filter, setFilter] = useState<'all' | 'low' | 'critical'>('all');
|
||||
const [categoryFilter, setCategoryFilter] = useState('all');
|
||||
|
||||
useEffect(() => {
|
||||
// Mock data
|
||||
setItems([
|
||||
{ id: '1', name: 'Ground Beef', category: 'Meats', currentStock: 45, parLevel: 100, unit: 'lbs', costPerUnit: 5.99, supplier: 'Premium Meats Co', lastRestocked: '2024-02-14' },
|
||||
{ id: '2', name: 'Chicken Breast', category: 'Meats', currentStock: 12, parLevel: 80, unit: 'lbs', costPerUnit: 4.50, supplier: 'Premium Meats Co', lastRestocked: '2024-02-13' },
|
||||
{ id: '3', name: 'Romaine Lettuce', category: 'Produce', currentStock: 8, parLevel: 30, unit: 'heads', costPerUnit: 1.25, supplier: 'Fresh Farms', lastRestocked: '2024-02-15' },
|
||||
{ id: '4', name: 'Tomatoes', category: 'Produce', currentStock: 18, parLevel: 40, unit: 'lbs', costPerUnit: 2.75, supplier: 'Fresh Farms', lastRestocked: '2024-02-15' },
|
||||
{ id: '5', name: 'Cheddar Cheese', category: 'Dairy', currentStock: 25, parLevel: 50, unit: 'lbs', costPerUnit: 6.25, supplier: 'Dairy Direct', lastRestocked: '2024-02-14' },
|
||||
{ id: '6', name: 'Milk', category: 'Dairy', currentStock: 5, parLevel: 20, unit: 'gallons', costPerUnit: 3.99, supplier: 'Dairy Direct', lastRestocked: '2024-02-12' },
|
||||
{ id: '7', name: 'Flour', category: 'Dry Goods', currentStock: 85, parLevel: 100, unit: 'lbs', costPerUnit: 0.89, supplier: 'Bulk Foods Inc', lastRestocked: '2024-02-10' },
|
||||
{ id: '8', name: 'Olive Oil', category: 'Dry Goods', currentStock: 6, parLevel: 15, unit: 'bottles', costPerUnit: 12.99, supplier: 'Bulk Foods Inc', lastRestocked: '2024-02-11' },
|
||||
{ id: '9', name: 'Bacon', category: 'Meats', currentStock: 22, parLevel: 40, unit: 'lbs', costPerUnit: 7.50, supplier: 'Premium Meats Co', lastRestocked: '2024-02-14' },
|
||||
{ id: '10', name: 'Onions', category: 'Produce', currentStock: 15, parLevel: 35, unit: 'lbs', costPerUnit: 1.50, supplier: 'Fresh Farms', lastRestocked: '2024-02-15' },
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const getStockStatus = (item: InventoryItem) => {
|
||||
const percentage = (item.currentStock / item.parLevel) * 100;
|
||||
if (percentage < 25) return 'critical';
|
||||
if (percentage < 50) return 'low';
|
||||
return 'good';
|
||||
};
|
||||
|
||||
const filteredItems = items.filter((item) => {
|
||||
const status = getStockStatus(item);
|
||||
const matchesFilter = filter === 'all' || status === filter;
|
||||
const matchesCategory = categoryFilter === 'all' || item.category === categoryFilter;
|
||||
return matchesFilter && matchesCategory;
|
||||
});
|
||||
|
||||
const categories = Array.from(new Set(items.map(i => i.category)));
|
||||
const lowStockCount = items.filter(i => getStockStatus(i) === 'low').length;
|
||||
const criticalCount = items.filter(i => getStockStatus(i) === 'critical').length;
|
||||
const totalValue = items.reduce((sum, i) => sum + (i.currentStock * i.costPerUnit), 0);
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<h1>📦 Inventory Tracker</h1>
|
||||
<p>Stock levels and alerts</p>
|
||||
</header>
|
||||
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Total Items</div>
|
||||
<div className="stat-value">{items.length}</div>
|
||||
</div>
|
||||
<div className="stat-card critical-card">
|
||||
<div className="stat-label">Critical Stock</div>
|
||||
<div className="stat-value critical">{criticalCount}</div>
|
||||
</div>
|
||||
<div className="stat-card low-card">
|
||||
<div className="stat-label">Low Stock</div>
|
||||
<div className="stat-value low">{lowStockCount}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Inventory Value</div>
|
||||
<div className="stat-value value">${totalValue.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filters">
|
||||
<div className="filter-group">
|
||||
{['all', 'low', 'critical'].map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
className={`filter-btn ${filter === status ? 'active' : ''}`}
|
||||
onClick={() => setFilter(status as any)}
|
||||
>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)} Stock
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<select
|
||||
className="category-select"
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">All Categories</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat} value={cat}>{cat}</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn-primary">+ Add Item</button>
|
||||
</div>
|
||||
|
||||
<div className="inventory-grid">
|
||||
{filteredItems.map((item) => {
|
||||
const status = getStockStatus(item);
|
||||
const percentage = Math.min((item.currentStock / item.parLevel) * 100, 100);
|
||||
|
||||
return (
|
||||
<div key={item.id} className={`inventory-card ${status}`}>
|
||||
<div className="item-header">
|
||||
<div>
|
||||
<h3>{item.name}</h3>
|
||||
<span className="category-badge">{item.category}</span>
|
||||
</div>
|
||||
<span className={`status-badge ${status}`}>
|
||||
{status === 'critical' && '🚨'}
|
||||
{status === 'low' && '⚠️'}
|
||||
{status === 'good' && '✓'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="stock-bar">
|
||||
<div
|
||||
className={`stock-fill ${status}`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div className="stock-info">
|
||||
<span className="current-stock">
|
||||
{item.currentStock} {item.unit}
|
||||
</span>
|
||||
<span className="par-level">
|
||||
Par: {item.parLevel} {item.unit}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="item-details">
|
||||
<div className="detail-row">
|
||||
<span>Cost/Unit:</span>
|
||||
<span className="value">${item.costPerUnit.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span>Total Value:</span>
|
||||
<span className="value">${(item.currentStock * item.costPerUnit).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span>Supplier:</span>
|
||||
<span className="value">{item.supplier}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span>Last Restocked:</span>
|
||||
<span className="value">
|
||||
{new Date(item.lastRestocked).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="item-actions">
|
||||
<button className="action-btn">Restock</button>
|
||||
<button className="action-btn">Adjust</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filteredItems.length === 0 && (
|
||||
<div className="no-results">No items found</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Inventory Tracker - TouchBistro MCP</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@ -0,0 +1,301 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2.5rem;
|
||||
color: #f8fafc;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.app-header p {
|
||||
color: #94a3b8;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card.critical-card {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.stat-card.low-card {
|
||||
border-color: #fbbf24;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.stat-value.critical {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.stat-value.low {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.stat-value.value {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #1e293b;
|
||||
border: 2px solid #334155;
|
||||
color: #cbd5e1;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: #334155;
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.category-select {
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: #1e293b;
|
||||
border: 2px solid #334155;
|
||||
color: #f8fafc;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.category-select:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.875rem 1.5rem;
|
||||
background: #3b82f6;
|
||||
border: none;
|
||||
color: #fff;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.inventory-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.inventory-card {
|
||||
background: #1e293b;
|
||||
border: 2px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.inventory-card.critical {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.inventory-card.low {
|
||||
border-color: #fbbf24;
|
||||
}
|
||||
|
||||
.inventory-card.good {
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.inventory-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.item-header h3 {
|
||||
color: #f8fafc;
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.category-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.625rem;
|
||||
background: #334155;
|
||||
color: #cbd5e1;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.stock-bar {
|
||||
height: 8px;
|
||||
background: #334155;
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.stock-fill {
|
||||
height: 100%;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.stock-fill.good {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.stock-fill.low {
|
||||
background: #fbbf24;
|
||||
}
|
||||
|
||||
.stock-fill.critical {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.stock-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
.current-stock {
|
||||
color: #f8fafc;
|
||||
font-weight: 700;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.par-level {
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.item-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.detail-row .value {
|
||||
color: #cbd5e1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
background: #334155;
|
||||
border: none;
|
||||
color: #cbd5e1;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #64748b;
|
||||
font-size: 1.125rem;
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
225
servers/touchbistro/src/ui/react-app/kitchen-display/App.tsx
Normal file
225
servers/touchbistro/src/ui/react-app/kitchen-display/App.tsx
Normal file
@ -0,0 +1,225 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import './styles.css';
|
||||
|
||||
interface OrderItem {
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
modifiers: string[];
|
||||
instructions?: string;
|
||||
status: 'new' | 'preparing' | 'ready';
|
||||
}
|
||||
|
||||
interface KitchenOrder {
|
||||
id: string;
|
||||
orderNumber: string;
|
||||
type: string;
|
||||
table?: string;
|
||||
server: string;
|
||||
items: OrderItem[];
|
||||
createdAt: string;
|
||||
elapsedMinutes: number;
|
||||
priority: 'normal' | 'high' | 'urgent';
|
||||
}
|
||||
|
||||
export default function KitchenDisplay() {
|
||||
const [orders, setOrders] = useState<KitchenOrder[]>([]);
|
||||
const [filter, setFilter] = useState<'all' | 'new' | 'preparing'>('all');
|
||||
|
||||
useEffect(() => {
|
||||
// Mock data
|
||||
setOrders([
|
||||
{
|
||||
id: '1',
|
||||
orderNumber: 'ORD-142',
|
||||
type: 'Dine In',
|
||||
table: 'Table 12',
|
||||
server: 'Mike',
|
||||
elapsedMinutes: 2,
|
||||
priority: 'normal',
|
||||
createdAt: new Date().toISOString(),
|
||||
items: [
|
||||
{ id: '1', name: 'Classic Burger', quantity: 2, modifiers: ['No Onions', 'Extra Cheese'], instructions: 'Medium rare', status: 'preparing' },
|
||||
{ id: '2', name: 'Caesar Salad', quantity: 1, modifiers: ['Add Chicken'], status: 'preparing' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
orderNumber: 'ORD-143',
|
||||
type: 'Takeout',
|
||||
server: 'Lisa',
|
||||
elapsedMinutes: 5,
|
||||
priority: 'normal',
|
||||
createdAt: new Date().toISOString(),
|
||||
items: [
|
||||
{ id: '3', name: 'Grilled Salmon', quantity: 1, modifiers: [], status: 'preparing' },
|
||||
{ id: '4', name: 'French Fries', quantity: 2, modifiers: [], status: 'ready' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
orderNumber: 'ORD-144',
|
||||
type: 'Delivery',
|
||||
server: 'Tom',
|
||||
elapsedMinutes: 12,
|
||||
priority: 'high',
|
||||
createdAt: new Date().toISOString(),
|
||||
items: [
|
||||
{ id: '5', name: 'Margherita Pizza', quantity: 2, modifiers: ['Extra Basil'], status: 'preparing' },
|
||||
{ id: '6', name: 'Chicken Wings', quantity: 1, modifiers: ['BBQ Sauce'], status: 'preparing' },
|
||||
{ id: '7', name: 'Garlic Bread', quantity: 1, modifiers: [], status: 'ready' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
orderNumber: 'ORD-145',
|
||||
type: 'Dine In',
|
||||
table: 'Table 5',
|
||||
server: 'Mike',
|
||||
elapsedMinutes: 18,
|
||||
priority: 'urgent',
|
||||
createdAt: new Date().toISOString(),
|
||||
items: [
|
||||
{ id: '8', name: 'Ribeye Steak', quantity: 1, modifiers: ['Medium Well'], instructions: 'Rush order', status: 'new' },
|
||||
{ id: '9', name: 'Mashed Potatoes', quantity: 1, modifiers: ['Extra Butter'], status: 'new' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
orderNumber: 'ORD-146',
|
||||
type: 'Dine In',
|
||||
table: 'Table 8',
|
||||
server: 'Lisa',
|
||||
elapsedMinutes: 1,
|
||||
priority: 'normal',
|
||||
createdAt: new Date().toISOString(),
|
||||
items: [
|
||||
{ id: '10', name: 'Fish & Chips', quantity: 2, modifiers: [], status: 'new' },
|
||||
{ id: '11', name: 'Coleslaw', quantity: 2, modifiers: [], status: 'new' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const filteredOrders = orders.filter((order) => {
|
||||
if (filter === 'all') return true;
|
||||
return order.items.some(item => item.status === filter);
|
||||
});
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
const colors = {
|
||||
normal: '#3b82f6',
|
||||
high: '#fbbf24',
|
||||
urgent: '#ef4444',
|
||||
};
|
||||
return colors[priority as keyof typeof colors];
|
||||
};
|
||||
|
||||
const stats = {
|
||||
total: orders.length,
|
||||
new: orders.filter(o => o.items.some(i => i.status === 'new')).length,
|
||||
preparing: orders.filter(o => o.items.some(i => i.status === 'preparing')).length,
|
||||
avgTime: Math.round(orders.reduce((sum, o) => sum + o.elapsedMinutes, 0) / orders.length),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app kds">
|
||||
<header className="kds-header">
|
||||
<h1>🍳 Kitchen Display</h1>
|
||||
<div className="header-stats">
|
||||
<div className="header-stat">
|
||||
<span className="stat-value">{stats.total}</span>
|
||||
<span className="stat-label">Active Orders</span>
|
||||
</div>
|
||||
<div className="header-stat">
|
||||
<span className="stat-value new">{stats.new}</span>
|
||||
<span className="stat-label">New</span>
|
||||
</div>
|
||||
<div className="header-stat">
|
||||
<span className="stat-value preparing">{stats.preparing}</span>
|
||||
<span className="stat-label">Preparing</span>
|
||||
</div>
|
||||
<div className="header-stat">
|
||||
<span className="stat-value time">{stats.avgTime}m</span>
|
||||
<span className="stat-label">Avg Time</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="filters">
|
||||
{['all', 'new', 'preparing'].map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
className={`filter-btn ${filter === status ? 'active' : ''}`}
|
||||
onClick={() => setFilter(status as any)}
|
||||
>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="orders-grid">
|
||||
{filteredOrders.map((order) => (
|
||||
<div
|
||||
key={order.id}
|
||||
className={`order-ticket ${order.priority}`}
|
||||
style={{ borderTopColor: getPriorityColor(order.priority) }}
|
||||
>
|
||||
<div className="ticket-header">
|
||||
<div className="ticket-info">
|
||||
<span className="order-number">{order.orderNumber}</span>
|
||||
<span className="order-type">{order.type}</span>
|
||||
{order.table && <span className="order-table">🪑 {order.table}</span>}
|
||||
</div>
|
||||
<div className="ticket-time">
|
||||
<span className={`elapsed ${order.priority}`}>
|
||||
{order.elapsedMinutes}m
|
||||
</span>
|
||||
<span className="server">Server: {order.server}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ticket-items">
|
||||
{order.items.map((item) => (
|
||||
<div key={item.id} className={`ticket-item ${item.status}`}>
|
||||
<div className="item-header">
|
||||
<span className="item-quantity">{item.quantity}×</span>
|
||||
<span className="item-name">{item.name}</span>
|
||||
<span className={`item-status ${item.status}`}>
|
||||
{item.status === 'new' && '🔴'}
|
||||
{item.status === 'preparing' && '🟡'}
|
||||
{item.status === 'ready' && '✓'}
|
||||
</span>
|
||||
</div>
|
||||
{item.modifiers.length > 0 && (
|
||||
<div className="item-modifiers">
|
||||
{item.modifiers.map((mod, idx) => (
|
||||
<span key={idx} className="modifier">• {mod}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{item.instructions && (
|
||||
<div className="item-instructions">📝 {item.instructions}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ticket-actions">
|
||||
<button className="btn-action start">Start</button>
|
||||
<button className="btn-action ready">Ready</button>
|
||||
<button className="btn-action complete">Complete</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredOrders.length === 0 && (
|
||||
<div className="no-orders">
|
||||
<div className="no-orders-icon">🎉</div>
|
||||
<div className="no-orders-text">All caught up!</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Kitchen Display - TouchBistro MCP</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
355
servers/touchbistro/src/ui/react-app/kitchen-display/styles.css
Normal file
355
servers/touchbistro/src/ui/react-app/kitchen-display/styles.css
Normal file
@ -0,0 +1,355 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app.kds {
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.kds-header {
|
||||
background: #1e293b;
|
||||
border: 2px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.kds-header h1 {
|
||||
font-size: 2rem;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.header-stats {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.header-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.header-stat .stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.header-stat .stat-value.new {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.header-stat .stat-value.preparing {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.header-stat .stat-value.time {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.header-stat .stat-label {
|
||||
color: #94a3b8;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 0.875rem 1.75rem;
|
||||
background: #1e293b;
|
||||
border: 2px solid #334155;
|
||||
color: #cbd5e1;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: #334155;
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.orders-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.order-ticket {
|
||||
background: #1e293b;
|
||||
border: 2px solid #334155;
|
||||
border-top: 6px solid;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.order-ticket.urgent {
|
||||
border-top-color: #ef4444;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px rgba(239, 68, 68, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.order-ticket.high {
|
||||
border-top-color: #fbbf24;
|
||||
}
|
||||
|
||||
.order-ticket.normal {
|
||||
border-top-color: #3b82f6;
|
||||
}
|
||||
|
||||
.ticket-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.25rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid #334155;
|
||||
}
|
||||
|
||||
.ticket-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.order-number {
|
||||
color: #f8fafc;
|
||||
font-weight: 700;
|
||||
font-size: 1.5rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.order-type {
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.order-table {
|
||||
color: #3b82f6;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ticket-time {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.elapsed {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
background: #334155;
|
||||
}
|
||||
|
||||
.elapsed.normal {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.elapsed.high {
|
||||
color: #fbbf24;
|
||||
background: #78350f;
|
||||
}
|
||||
|
||||
.elapsed.urgent {
|
||||
color: #fff;
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.server {
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.ticket-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.ticket-item {
|
||||
background: #0f172a;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border-left: 4px solid #334155;
|
||||
}
|
||||
|
||||
.ticket-item.new {
|
||||
border-left-color: #ef4444;
|
||||
}
|
||||
|
||||
.ticket-item.preparing {
|
||||
border-left-color: #fbbf24;
|
||||
}
|
||||
|
||||
.ticket-item.ready {
|
||||
border-left-color: #10b981;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.item-quantity {
|
||||
color: #f8fafc;
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
min-width: 2rem;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
color: #f8fafc;
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item-status {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.item-modifiers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-left: 2.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.modifier {
|
||||
color: #fbbf24;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.item-instructions {
|
||||
margin-left: 2.75rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
background: #1e293b;
|
||||
border-left: 3px solid #ef4444;
|
||||
border-radius: 0.25rem;
|
||||
color: #fca5a5;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ticket-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.btn-action.start {
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-action.start:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-action.ready {
|
||||
background: #fbbf24;
|
||||
color: #78350f;
|
||||
}
|
||||
|
||||
.btn-action.ready:hover {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.btn-action.complete {
|
||||
background: #10b981;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-action.complete:hover {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.no-orders {
|
||||
text-align: center;
|
||||
padding: 6rem 2rem;
|
||||
background: #1e293b;
|
||||
border: 2px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.no-orders-icon {
|
||||
font-size: 5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.no-orders-text {
|
||||
color: #94a3b8;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
177
servers/touchbistro/src/ui/react-app/payment-summary/App.tsx
Normal file
177
servers/touchbistro/src/ui/react-app/payment-summary/App.tsx
Normal file
@ -0,0 +1,177 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import './styles.css';
|
||||
|
||||
interface PaymentMethodBreakdown {
|
||||
method: string;
|
||||
count: number;
|
||||
amount: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
interface Payment {
|
||||
id: string;
|
||||
orderNumber: string;
|
||||
method: string;
|
||||
amount: number;
|
||||
tip: number;
|
||||
total: number;
|
||||
time: string;
|
||||
status: 'completed' | 'refunded';
|
||||
}
|
||||
|
||||
export default function PaymentSummary() {
|
||||
const [breakdown, setBreakdown] = useState<PaymentMethodBreakdown[]>([]);
|
||||
const [recentPayments, setRecentPayments] = useState<Payment[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Mock data
|
||||
setBreakdown([
|
||||
{ method: 'Credit Card', count: 82, amount: 5847.25, percentage: 68.4 },
|
||||
{ method: 'Cash', count: 23, amount: 1234.50, percentage: 14.5 },
|
||||
{ method: 'Debit Card', count: 18, amount: 987.40, percentage: 11.6 },
|
||||
{ method: 'Mobile Payment', count: 12, amount: 478.17, percentage: 5.5 },
|
||||
]);
|
||||
|
||||
setRecentPayments([
|
||||
{ id: '1', orderNumber: 'ORD-142', method: 'Credit Card', amount: 68.50, tip: 12.00, total: 80.50, time: '14:32', status: 'completed' },
|
||||
{ id: '2', orderNumber: 'ORD-141', method: 'Cash', amount: 45.00, tip: 8.00, total: 53.00, time: '14:28', status: 'completed' },
|
||||
{ id: '3', orderNumber: 'ORD-140', method: 'Mobile Payment', amount: 92.75, tip: 18.00, total: 110.75, time: '14:25', status: 'completed' },
|
||||
{ id: '4', orderNumber: 'ORD-139', method: 'Credit Card', amount: 54.25, tip: 10.00, total: 64.25, time: '14:20', status: 'completed' },
|
||||
{ id: '5', orderNumber: 'ORD-138', method: 'Debit Card', amount: 78.90, tip: 15.00, total: 93.90, time: '14:15', status: 'completed' },
|
||||
{ id: '6', orderNumber: 'ORD-137', method: 'Credit Card', amount: 34.20, tip: 0.00, total: 34.20, time: '14:10', status: 'refunded' },
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const totalAmount = breakdown.reduce((sum, b) => sum + b.amount, 0);
|
||||
const totalTips = recentPayments.reduce((sum, p) => sum + p.tip, 0);
|
||||
const totalRefunds = recentPayments.filter(p => p.status === 'refunded').reduce((sum, p) => sum + p.total, 0);
|
||||
const totalTransactions = breakdown.reduce((sum, b) => sum + b.count, 0);
|
||||
|
||||
const getMethodColor = (method: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
'Credit Card': '#3b82f6',
|
||||
'Cash': '#10b981',
|
||||
'Debit Card': '#8b5cf6',
|
||||
'Mobile Payment': '#fbbf24',
|
||||
};
|
||||
return colors[method] || '#6b7280';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<h1>💳 Payment Summary</h1>
|
||||
<p>Payment breakdown and transactions</p>
|
||||
</header>
|
||||
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Total Payments</div>
|
||||
<div className="stat-value">${totalAmount.toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Total Tips</div>
|
||||
<div className="stat-value tips">${totalTips.toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Refunds</div>
|
||||
<div className="stat-value refunds">${totalRefunds.toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Transactions</div>
|
||||
<div className="stat-value">{totalTransactions}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="content-grid">
|
||||
<div className="breakdown-panel">
|
||||
<h2>Payment Methods</h2>
|
||||
<div className="breakdown-list">
|
||||
{breakdown.map((item) => (
|
||||
<div key={item.method} className="breakdown-item">
|
||||
<div className="breakdown-header">
|
||||
<div>
|
||||
<span
|
||||
className="method-dot"
|
||||
style={{ backgroundColor: getMethodColor(item.method) }}
|
||||
></span>
|
||||
<span className="method-name">{item.method}</span>
|
||||
</div>
|
||||
<span className="method-count">{item.count} transactions</span>
|
||||
</div>
|
||||
<div className="breakdown-bar">
|
||||
<div
|
||||
className="breakdown-fill"
|
||||
style={{
|
||||
width: `${item.percentage}%`,
|
||||
backgroundColor: getMethodColor(item.method),
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<div className="breakdown-footer">
|
||||
<span className="breakdown-amount">${item.amount.toFixed(2)}</span>
|
||||
<span className="breakdown-percentage">{item.percentage}%</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="chart-container">
|
||||
<div className="pie-chart">
|
||||
{breakdown.map((item, index) => (
|
||||
<div
|
||||
key={item.method}
|
||||
className="pie-slice"
|
||||
style={{
|
||||
backgroundColor: getMethodColor(item.method),
|
||||
transform: `rotate(${breakdown.slice(0, index).reduce((sum, b) => sum + (b.percentage * 3.6), 0)}deg)`,
|
||||
}}
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="transactions-panel">
|
||||
<h2>Recent Transactions</h2>
|
||||
<div className="transactions-list">
|
||||
{recentPayments.map((payment) => (
|
||||
<div key={payment.id} className={`transaction-card ${payment.status}`}>
|
||||
<div className="transaction-header">
|
||||
<span className="order-number">{payment.orderNumber}</span>
|
||||
<span className="transaction-time">{payment.time}</span>
|
||||
</div>
|
||||
<div className="transaction-body">
|
||||
<div className="transaction-details">
|
||||
<span
|
||||
className="payment-method"
|
||||
style={{ color: getMethodColor(payment.method) }}
|
||||
>
|
||||
{payment.method}
|
||||
</span>
|
||||
<div className="amounts">
|
||||
<span className="amount">${payment.amount.toFixed(2)}</span>
|
||||
{payment.tip > 0 && (
|
||||
<span className="tip">+${payment.tip.toFixed(2)} tip</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="transaction-total">
|
||||
<span className="total-label">Total</span>
|
||||
<span className={`total-amount ${payment.status}`}>
|
||||
{payment.status === 'refunded' && '−'}
|
||||
${payment.total.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{payment.status === 'refunded' && (
|
||||
<div className="refund-badge">REFUNDED</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Payment Summary - TouchBistro MCP</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
293
servers/touchbistro/src/ui/react-app/payment-summary/styles.css
Normal file
293
servers/touchbistro/src/ui/react-app/payment-summary/styles.css
Normal file
@ -0,0 +1,293 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2.5rem;
|
||||
color: #f8fafc;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.app-header p {
|
||||
color: #94a3b8;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.stat-value.tips {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.stat-value.refunds {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.breakdown-panel,
|
||||
.transactions-panel {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #f8fafc;
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.breakdown-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.breakdown-item {
|
||||
background: #0f172a;
|
||||
padding: 1.25rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.breakdown-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.breakdown-header > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.method-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.method-name {
|
||||
color: #f8fafc;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.method-count {
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.breakdown-bar {
|
||||
height: 8px;
|
||||
background: #334155;
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.breakdown-fill {
|
||||
height: 100%;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.breakdown-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.breakdown-amount {
|
||||
color: #10b981;
|
||||
font-weight: 700;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.breakdown-percentage {
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.pie-chart {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: conic-gradient(
|
||||
#3b82f6 0deg 68.4deg,
|
||||
#10b981 68.4deg 137.4deg,
|
||||
#8b5cf6 137.4deg 185.4deg,
|
||||
#fbbf24 185.4deg 360deg
|
||||
);
|
||||
}
|
||||
|
||||
.transactions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.transaction-card {
|
||||
background: #0f172a;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid #334155;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.transaction-card.refunded {
|
||||
border-color: #ef4444;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.transaction-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.order-number {
|
||||
color: #f8fafc;
|
||||
font-weight: 600;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.transaction-time {
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.transaction-body {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.transaction-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.payment-method {
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.amounts {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.amount {
|
||||
color: #cbd5e1;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.tip {
|
||||
color: #10b981;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.transaction-total {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.total-label {
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.total-amount {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.total-amount.refunded {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.refund-badge {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
padding: 0.25rem 0.625rem;
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
155
servers/touchbistro/src/ui/react-app/sales-dashboard/App.tsx
Normal file
155
servers/touchbistro/src/ui/react-app/sales-dashboard/App.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import './styles.css';
|
||||
|
||||
interface HourlySales {
|
||||
hour: string;
|
||||
revenue: number;
|
||||
orders: number;
|
||||
}
|
||||
|
||||
interface TopItem {
|
||||
name: string;
|
||||
quantity: number;
|
||||
revenue: number;
|
||||
}
|
||||
|
||||
export default function SalesDashboard() {
|
||||
const [hourlySales, setHourlySales] = useState<HourlySales[]>([]);
|
||||
const [topItems, setTopItems] = useState<TopItem[]>([]);
|
||||
const [stats, setStats] = useState({
|
||||
totalRevenue: 0,
|
||||
totalOrders: 0,
|
||||
avgTicket: 0,
|
||||
salesVsGoal: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Mock data
|
||||
setStats({
|
||||
totalRevenue: 8547.32,
|
||||
totalOrders: 142,
|
||||
avgTicket: 60.19,
|
||||
salesVsGoal: 85.5,
|
||||
});
|
||||
|
||||
setHourlySales([
|
||||
{ hour: '11am', revenue: 847.50, orders: 15 },
|
||||
{ hour: '12pm', revenue: 1568.75, orders: 28 },
|
||||
{ hour: '1pm', revenue: 1892.40, orders: 32 },
|
||||
{ hour: '2pm', revenue: 1034.50, orders: 18 },
|
||||
{ hour: '3pm', revenue: 678.20, orders: 12 },
|
||||
{ hour: '4pm', revenue: 445.80, orders: 8 },
|
||||
{ hour: '5pm', revenue: 1245.60, orders: 22 },
|
||||
{ hour: '6pm', revenue: 834.57, orders: 35 },
|
||||
]);
|
||||
|
||||
setTopItems([
|
||||
{ name: 'Classic Burger', quantity: 45, revenue: 674.55 },
|
||||
{ name: 'Caesar Salad', quantity: 38, revenue: 379.62 },
|
||||
{ name: 'Grilled Salmon', quantity: 32, revenue: 799.68 },
|
||||
{ name: 'Chicken Wings', quantity: 28, revenue: 363.72 },
|
||||
{ name: 'French Fries', quantity: 52, revenue: 259.48 },
|
||||
{ name: 'Margherita Pizza', quantity: 24, revenue: 407.76 },
|
||||
{ name: 'Chocolate Cake', quantity: 22, revenue: 175.78 },
|
||||
{ name: 'Craft Beer', quantity: 56, revenue: 391.44 },
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const maxRevenue = Math.max(...hourlySales.map(h => h.revenue));
|
||||
const maxQuantity = Math.max(...topItems.map(i => i.quantity));
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<h1>📈 Sales Dashboard</h1>
|
||||
<p>Revenue analytics and top performers</p>
|
||||
</header>
|
||||
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Total Revenue</div>
|
||||
<div className="stat-value revenue">${stats.totalRevenue.toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Total Orders</div>
|
||||
<div className="stat-value">{stats.totalOrders}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Average Ticket</div>
|
||||
<div className="stat-value">${stats.avgTicket.toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="stat-card goal-card">
|
||||
<div className="stat-label">Sales vs Goal</div>
|
||||
<div className="stat-value goal">{stats.salesVsGoal}%</div>
|
||||
<div className="goal-bar">
|
||||
<div
|
||||
className="goal-fill"
|
||||
style={{ width: `${Math.min(stats.salesVsGoal, 100)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="content-grid">
|
||||
<div className="chart-panel">
|
||||
<h2>Revenue by Hour</h2>
|
||||
<div className="hourly-chart">
|
||||
{hourlySales.map((item) => (
|
||||
<div key={item.hour} className="hour-bar">
|
||||
<div
|
||||
className="bar-fill"
|
||||
style={{ height: `${(item.revenue / maxRevenue) * 100}%` }}
|
||||
>
|
||||
<div className="bar-tooltip">
|
||||
<div className="tooltip-revenue">${item.revenue.toFixed(0)}</div>
|
||||
<div className="tooltip-orders">{item.orders} orders</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bar-label">{item.hour}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="summary-cards">
|
||||
<div className="summary-card">
|
||||
<div className="summary-label">Peak Hour</div>
|
||||
<div className="summary-value">
|
||||
{hourlySales.reduce((max, h) => h.revenue > max.revenue ? h : max).hour}
|
||||
</div>
|
||||
</div>
|
||||
<div className="summary-card">
|
||||
<div className="summary-label">Peak Revenue</div>
|
||||
<div className="summary-value">
|
||||
${Math.max(...hourlySales.map(h => h.revenue)).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="top-items-panel">
|
||||
<h2>Top Selling Items</h2>
|
||||
<div className="items-list">
|
||||
{topItems.map((item, index) => (
|
||||
<div key={item.name} className="item-row">
|
||||
<div className="item-rank">#{index + 1}</div>
|
||||
<div className="item-info">
|
||||
<div className="item-name">{item.name}</div>
|
||||
<div className="item-bar">
|
||||
<div
|
||||
className="item-bar-fill"
|
||||
style={{ width: `${(item.quantity / maxQuantity) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="item-stats">
|
||||
<span className="item-quantity">{item.quantity} sold</span>
|
||||
<span className="item-revenue">${item.revenue.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sales Dashboard - TouchBistro MCP</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
280
servers/touchbistro/src/ui/react-app/sales-dashboard/styles.css
Normal file
280
servers/touchbistro/src/ui/react-app/sales-dashboard/styles.css
Normal file
@ -0,0 +1,280 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2.5rem;
|
||||
color: #f8fafc;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.app-header p {
|
||||
color: #94a3b8;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card.goal-card {
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.stat-value.revenue {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.stat-value.goal {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.goal-bar {
|
||||
height: 6px;
|
||||
background: #334155;
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.goal-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(to right, #10b981, #34d399);
|
||||
transition: width 0.5s;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-panel,
|
||||
.top-items-panel {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #f8fafc;
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.hourly-chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
height: 300px;
|
||||
padding: 1rem;
|
||||
background: #0f172a;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.hour-bar {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
width: 100%;
|
||||
background: linear-gradient(to top, #3b82f6, #60a5fa);
|
||||
border-radius: 0.25rem;
|
||||
position: relative;
|
||||
min-height: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.bar-fill:hover {
|
||||
background: linear-gradient(to top, #2563eb, #3b82f6);
|
||||
}
|
||||
|
||||
.bar-tooltip {
|
||||
position: absolute;
|
||||
top: -3.5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.5rem;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bar-fill:hover .bar-tooltip {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tooltip-revenue {
|
||||
color: #10b981;
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.tooltip-orders {
|
||||
color: #94a3b8;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
color: #94a3b8;
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.summary-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: #0f172a;
|
||||
padding: 1.25rem;
|
||||
border-radius: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
color: #f8fafc;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.items-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.item-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
background: #0f172a;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.item-rank {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #3b82f6;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
color: #f8fafc;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.item-bar {
|
||||
height: 6px;
|
||||
background: #334155;
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.item-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(to right, #10b981, #34d399);
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.item-stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.item-quantity {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.item-revenue {
|
||||
color: #10b981;
|
||||
font-weight: 700;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user