1012 lines
58 KiB
TypeScript
1012 lines
58 KiB
TypeScript
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
|
import {
|
|
FaArrowLeft, FaSave, FaEdit, FaTimes, FaPlus, FaTrash,
|
|
FaSpinner, FaShoppingCart, FaPaperPlane, FaTruck,
|
|
FaChevronDown, FaChevronRight, FaPencilAlt,
|
|
FaFileInvoiceDollar, FaBoxes, FaFolderOpen,
|
|
} from 'react-icons/fa';
|
|
import { toast, ToastContainer } from 'react-toastify';
|
|
import 'react-toastify/dist/ReactToastify.css';
|
|
import salesOrderService, { SalesOrder, SalesOrderItem, SalesTaxCharge } from '../services/salesOrderService';
|
|
import LinkField from '../components/LinkField';
|
|
import ActivityLog from '../components/ActivityLog';
|
|
import { formatFrappeApiError } from '../utils/frappeErrorMessage';
|
|
import {
|
|
DEFAULT_COMPANY, DEFAULT_CURRENCY, DEFAULT_SALES_TAXES_TEMPLATE,
|
|
taxRatePercent, displayTxnCurrency,
|
|
} from '../constants/orgDefaults';
|
|
|
|
// ── Shared helpers ────────────────────────────────────────────────────────────
|
|
const FL: React.FC<{ children: React.ReactNode; required?: boolean }> = ({ children, required }) => (
|
|
<label className="block text-[11px] font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
|
|
{children}{required && <span className="text-red-500 ml-0.5">*</span>}
|
|
</label>
|
|
);
|
|
const RV: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
|
|
<div className="px-3 py-2 text-sm text-gray-800 dark:text-gray-200 bg-gray-50 dark:bg-gray-800/60 rounded min-h-[34px] flex items-center">
|
|
{children || <span className="text-gray-400">-</span>}
|
|
</div>
|
|
);
|
|
const inputCls = 'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-blue-400';
|
|
const numCls = inputCls + ' text-right';
|
|
const inlineNum = 'w-full px-2 py-1 text-sm text-right border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-400';
|
|
const inlineTxt = 'w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-400';
|
|
|
|
// ── Create Dropdown ───────────────────────────────────────────────────────────
|
|
const CreateDropdown: React.FC<{
|
|
items: { label: string; icon: React.ReactNode; onClick: () => void }[];
|
|
}> = ({ items }) => {
|
|
const [open, setOpen] = useState(false);
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
const handler = (e: MouseEvent) => {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
};
|
|
document.addEventListener('mousedown', handler);
|
|
return () => document.removeEventListener('mousedown', handler);
|
|
}, []);
|
|
|
|
return (
|
|
<div className="relative" ref={ref}>
|
|
<button
|
|
onClick={() => setOpen(o => !o)}
|
|
className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium shadow-sm"
|
|
>
|
|
Create
|
|
<FaChevronDown size={10} className={`transition-transform ${open ? 'rotate-180' : ''}`} />
|
|
</button>
|
|
{open && (
|
|
<div className="absolute right-0 mt-1 w-52 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-xl z-50 py-1.5 overflow-hidden">
|
|
<div className="px-3 py-1.5 text-[10px] font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider border-b border-gray-100 dark:border-gray-700 mb-1">
|
|
Create from this order
|
|
</div>
|
|
{items.map(({ label, icon, onClick }) => (
|
|
<button
|
|
key={label}
|
|
onClick={() => { onClick(); setOpen(false); }}
|
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-700 dark:hover:text-blue-300 transition-colors text-left"
|
|
>
|
|
<span className="text-gray-400 group-hover:text-blue-500">{icon}</span>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ── Collapsible group inside row editor ───────────────────────────────────────
|
|
const RGroup: React.FC<{ title: string; children: React.ReactNode; defaultOpen?: boolean }> = ({ title, children, defaultOpen = false }) => {
|
|
const [open, setOpen] = useState(defaultOpen);
|
|
return (
|
|
<div className="border-t border-gray-200 dark:border-gray-600 mt-3 pt-1">
|
|
<button type="button" onClick={() => setOpen(o => !o)}
|
|
className="flex items-center gap-2 py-1 text-xs font-semibold text-blue-600 dark:text-blue-400 hover:underline">
|
|
{open ? <FaChevronDown size={9} /> : <FaChevronRight size={9} />}{title}
|
|
</button>
|
|
{open && <div className="mt-2">{children}</div>}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ── Item Row Editor ───────────────────────────────────────────────────────────
|
|
const SOItemRowEditor: React.FC<{
|
|
item: Partial<SalesOrderItem>; rowNo: number;
|
|
onChange: (k: keyof SalesOrderItem, v: any) => void;
|
|
onClose: () => void; onDelete: () => void; onInsertBelow: () => void;
|
|
}> = ({ item, rowNo, onChange, onClose, onDelete, onInsertBelow }) => (
|
|
<tr>
|
|
<td colSpan={8} className="p-0">
|
|
<div className="border border-blue-300 dark:border-blue-600 rounded-lg mx-2 my-1 bg-white dark:bg-gray-800 shadow-md">
|
|
{/* Editor header */}
|
|
<div className="flex items-center justify-between px-4 py-2 bg-blue-50 dark:bg-blue-900/30 rounded-t-lg border-b border-blue-200 dark:border-blue-700">
|
|
<span className="text-sm font-semibold text-blue-700 dark:text-blue-300">Editing Row #{rowNo}</span>
|
|
<div className="flex items-center gap-2">
|
|
<button onClick={onInsertBelow} className="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-600">Insert Below</button>
|
|
<button onClick={onDelete} className="px-2 py-1 text-xs border border-red-300 rounded text-red-500 hover:bg-red-50">Delete</button>
|
|
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600"><FaTimes size={13} /></button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-4 space-y-4">
|
|
{/* Row 1: Item Code + Delivery Date */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div><FL required>Item Code</FL>
|
|
<LinkField label="Item" hideLabel doctype="Item" value={item.item_code || ''} onChange={v => onChange('item_code', v)} placeholder="Select item…" />
|
|
</div>
|
|
<div><FL>Delivery Date</FL>
|
|
<input type="date" value={item.delivery_date || ''} onChange={e => onChange('delivery_date', e.target.value)} className={inputCls} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Ensure delivery checkbox + Item Name */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="flex items-center gap-2 pt-4">
|
|
<input type="checkbox" checked={!!(item.ensure_delivery_based_on_produced_serial_no)} onChange={e => onChange('ensure_delivery_based_on_produced_serial_no', e.target.checked ? 1 : 0)} className="rounded" />
|
|
<span className="text-xs text-gray-600 dark:text-gray-400">Ensure Delivery Based on Produced Serial No</span>
|
|
</div>
|
|
<div><FL required>Item Name</FL>
|
|
<input value={item.item_name || ''} onChange={e => onChange('item_name', e.target.value)} className={inputCls} placeholder="Item name…" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<RGroup title="Description" defaultOpen={!!(item.description)}>
|
|
<textarea rows={2} value={item.description || ''} onChange={e => onChange('description', e.target.value)} className={inputCls} placeholder="Description…" />
|
|
</RGroup>
|
|
|
|
{/* Quantity and Rate */}
|
|
<RGroup title="Quantity and Rate" defaultOpen>
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<div><FL required>Quantity</FL>
|
|
<input type="number" min={0} step="1" value={item.qty ?? 0} onChange={e => onChange('qty', parseFloat(e.target.value) || 0)} className={numCls} />
|
|
</div>
|
|
<div><FL required>UOM</FL>
|
|
<LinkField label="UOM" hideLabel doctype="UOM" value={item.uom || ''} onChange={v => onChange('uom', v)} placeholder="UOM…" />
|
|
</div>
|
|
<div><FL>Stock UOM</FL>
|
|
<LinkField label="Stock UOM" hideLabel doctype="UOM" value={item.stock_uom || ''} onChange={v => onChange('stock_uom', v)} placeholder="Stock UOM…" />
|
|
</div>
|
|
<div><FL required>UOM Conversion Factor</FL>
|
|
<input type="number" min={0} step="0.001" value={item.conversion_factor ?? 1} onChange={e => onChange('conversion_factor', parseFloat(e.target.value) || 1)} className={numCls} />
|
|
</div>
|
|
<div><FL>Qty as per Stock UOM</FL>
|
|
<div className="px-3 py-2 text-sm text-gray-600 bg-gray-50 dark:bg-gray-700 rounded text-right">
|
|
{((item.qty || 0) * (item.conversion_factor || 1)).toFixed(3)}
|
|
</div>
|
|
</div>
|
|
<div><FL>Price List Rate</FL>
|
|
<input type="number" min={0} step="0.01" value={item.price_list_rate ?? 0} onChange={e => onChange('price_list_rate', parseFloat(e.target.value) || 0)} className={numCls} />
|
|
</div>
|
|
</div>
|
|
</RGroup>
|
|
|
|
{/* Discount and Margin */}
|
|
<RGroup title="Discount and Margin">
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<div><FL required>Rate</FL>
|
|
<input type="number" min={0} step="0.01" value={item.rate ?? 0} onChange={e => onChange('rate', parseFloat(e.target.value) || 0)} className={numCls} />
|
|
</div>
|
|
<div><FL>Rate of Stock UOM</FL>
|
|
<div className="px-3 py-2 text-sm text-right bg-gray-50 dark:bg-gray-700 rounded">{(item.stock_uom_rate ?? 0).toFixed(2)}</div>
|
|
</div>
|
|
<div><FL required>Amount</FL>
|
|
<div className="px-3 py-2 text-sm font-semibold text-right bg-gray-50 dark:bg-gray-700 rounded">
|
|
{((item.qty || 0) * (item.rate || 0)).toFixed(2)}
|
|
</div>
|
|
</div>
|
|
<div><FL>Item Tax Template</FL>
|
|
<input value={item.item_tax_template || ''} onChange={e => onChange('item_tax_template', e.target.value)} className={inputCls} placeholder="Tax template…" />
|
|
</div>
|
|
<div><FL>Billed Amount</FL>
|
|
<div className="px-3 py-2 text-sm text-right bg-gray-50 dark:bg-gray-700 rounded">{(item.billed_amt ?? 0).toFixed(2)}</div>
|
|
</div>
|
|
<div><FL>Valuation Rate</FL>
|
|
<div className="px-3 py-2 text-sm text-right bg-gray-50 dark:bg-gray-700 rounded">{(item.valuation_rate ?? 0).toFixed(2)}</div>
|
|
</div>
|
|
<div><FL>Gross Profit</FL>
|
|
<div className="px-3 py-2 text-sm text-right bg-gray-50 dark:bg-gray-700 rounded">{(item.gross_profit ?? 0).toFixed(2)}</div>
|
|
</div>
|
|
<div className="flex flex-col justify-end gap-2 pb-1">
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" checked={!!(item.is_free_item)} onChange={e => onChange('is_free_item', e.target.checked ? 1 : 0)} className="rounded" />
|
|
<span className="text-xs text-gray-600 dark:text-gray-400">Is Free Item</span>
|
|
</label>
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" checked={item.grant_commission !== 0} onChange={e => onChange('grant_commission', e.target.checked ? 1 : 0)} className="rounded" />
|
|
<span className="text-xs text-gray-600 dark:text-gray-400">Grant Commission</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</RGroup>
|
|
|
|
{/* Drop Ship */}
|
|
<RGroup title="Drop Ship">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="flex items-center gap-2">
|
|
<input type="checkbox" checked={!!(item.delivered_by_supplier)} onChange={e => onChange('delivered_by_supplier', e.target.checked ? 1 : 0)} className="rounded" />
|
|
<span className="text-xs text-gray-600 dark:text-gray-400">Supplier delivers to Customer</span>
|
|
</div>
|
|
{item.delivered_by_supplier ? (
|
|
<div><FL>Supplier</FL>
|
|
<LinkField label="Supplier" hideLabel doctype="Supplier" value={item.supplier || ''} onChange={v => onChange('supplier', v)} placeholder="Select supplier…" />
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</RGroup>
|
|
|
|
{/* Item Weight Details */}
|
|
<RGroup title="Item Weight Details">
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<div><FL>Weight Per Unit</FL>
|
|
<input type="number" min={0} step="0.001" value={item.weight_per_unit ?? 0} onChange={e => onChange('weight_per_unit', parseFloat(e.target.value) || 0)} className={numCls} />
|
|
</div>
|
|
<div><FL>Weight UOM</FL>
|
|
<input value={item.weight_uom || ''} onChange={e => onChange('weight_uom', e.target.value)} className={inputCls} placeholder="UOM…" />
|
|
</div>
|
|
<div><FL>Total Weight</FL>
|
|
<div className="px-3 py-2 text-sm text-right bg-gray-50 dark:bg-gray-700 rounded">{(item.total_weight ?? 0).toFixed(3)}</div>
|
|
</div>
|
|
</div>
|
|
</RGroup>
|
|
|
|
{/* Warehouse and Reference */}
|
|
<RGroup title="Warehouse and Reference">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div><FL>Delivery Warehouse</FL>
|
|
<LinkField label="Warehouse" hideLabel doctype="Warehouse" value={item.delivery_warehouse || ''} onChange={v => onChange('delivery_warehouse', v)} placeholder="Warehouse…" />
|
|
</div>
|
|
<div className="flex items-end pb-2">
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" checked={!!(item.against_blanket_order)} onChange={e => onChange('against_blanket_order', e.target.checked ? 1 : 0)} className="rounded" />
|
|
<span className="text-xs text-gray-600 dark:text-gray-400">Against Blanket Order</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</RGroup>
|
|
|
|
{/* Available Quantity (read-only) */}
|
|
<RGroup title="Available Quantity">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div><FL>Qty (Warehouse)</FL>
|
|
<div className="px-3 py-2 text-sm text-right bg-gray-50 dark:bg-gray-700 rounded">{item.actual_qty ?? 0}</div>
|
|
</div>
|
|
<div><FL>Qty (Company)</FL>
|
|
<div className="px-3 py-2 text-sm text-right bg-gray-50 dark:bg-gray-700 rounded">{item.company_total_stock ?? 0}</div>
|
|
</div>
|
|
</div>
|
|
</RGroup>
|
|
|
|
{/* Manufacturing */}
|
|
<RGroup title="Manufacturing Section">
|
|
<div><FL>BOM No</FL>
|
|
<LinkField label="BOM" hideLabel doctype="BOM" value={item.bom_no || ''} onChange={v => onChange('bom_no', v)} placeholder="BOM…" />
|
|
</div>
|
|
</RGroup>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
|
|
// ── Tax Row Editor ────────────────────────────────────────────────────────────
|
|
const SOTaxRowEditor: React.FC<{
|
|
tax: Partial<SalesTaxCharge>; rowNo: number;
|
|
onChange: (k: keyof SalesTaxCharge, v: any) => void;
|
|
onClose: () => void; onDelete: () => void; onInsertBelow: () => void;
|
|
}> = ({ tax, rowNo, onChange, onClose, onDelete, onInsertBelow }) => (
|
|
<tr>
|
|
<td colSpan={6} className="p-0">
|
|
<div className="border border-blue-300 dark:border-blue-600 rounded-lg mx-2 my-1 bg-white dark:bg-gray-800 shadow-md">
|
|
<div className="flex items-center justify-between px-4 py-2 bg-blue-50 dark:bg-blue-900/30 rounded-t-lg border-b border-blue-200">
|
|
<span className="text-sm font-semibold text-blue-700 dark:text-blue-300">Editing Row #{rowNo}</span>
|
|
<div className="flex items-center gap-2">
|
|
<button onClick={onInsertBelow} className="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 text-gray-600">Insert Below</button>
|
|
<button onClick={onDelete} className="px-2 py-1 text-xs border border-red-300 rounded text-red-500 hover:bg-red-50">Delete</button>
|
|
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600"><FaTimes size={13} /></button>
|
|
</div>
|
|
</div>
|
|
<div className="p-4 space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div><FL required>Type</FL>
|
|
<select value={tax.charge_type || ''} onChange={e => onChange('charge_type', e.target.value)} className={inputCls}>
|
|
<option value="">Select type…</option>
|
|
<option value="Actual">Actual</option>
|
|
<option value="On Net Total">On Net Total</option>
|
|
<option value="On Previous Row Amount">On Previous Row Amount</option>
|
|
<option value="On Previous Row Total">On Previous Row Total</option>
|
|
<option value="On Item Quantity">On Item Quantity</option>
|
|
<option value="Inter Company Transaction">Inter Company Transaction</option>
|
|
</select>
|
|
</div>
|
|
<div><FL>Description</FL>
|
|
<textarea rows={3} value={tax.description || ''} onChange={e => onChange('description', e.target.value)} className={inputCls} placeholder="Description…" />
|
|
</div>
|
|
</div>
|
|
<div><FL required>Account Head</FL>
|
|
<LinkField label="Account Head" hideLabel doctype="Account" value={tax.account_head || ''} onChange={v => onChange('account_head', v)} placeholder="Account…" />
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<input type="checkbox" checked={!!(tax.included_in_print_rate)} onChange={e => onChange('included_in_print_rate', e.target.checked ? 1 : 0)} className="rounded" />
|
|
<span className="text-xs text-gray-600 dark:text-gray-400">Is this Tax included in Basic Rate?</span>
|
|
</div>
|
|
<p className="text-xs text-blue-500 dark:text-blue-400">If checked, the tax amount will be considered as already included in the Print Rate / Print Amount</p>
|
|
|
|
<RGroup title="Accounting Dimensions" defaultOpen>
|
|
<div><FL>Cost Center</FL>
|
|
<LinkField label="Cost Center" hideLabel doctype="Cost Center" value={tax.cost_center || ''} onChange={v => onChange('cost_center', v)} placeholder="Cost center…" />
|
|
</div>
|
|
</RGroup>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div><FL required>Tax Rate</FL>
|
|
<input type="number" min={0} step="0.01" value={taxRatePercent(tax)} onChange={e => onChange('tax_rate', parseFloat(e.target.value) || 0)} className={numCls} />
|
|
</div>
|
|
<div><FL>Account Currency</FL>
|
|
<input value={tax.account_currency || DEFAULT_CURRENCY} onChange={e => onChange('account_currency', e.target.value)} className={inputCls} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
|
|
// ── Page ──────────────────────────────────────────────────────────────────────
|
|
const emptyItem = (): Partial<SalesOrderItem> => ({ item_code: '', item_name: '', qty: 1, rate: 0, amount: 0, uom: '', conversion_factor: 1, is_free_item: 0, grant_commission: 0 });
|
|
const emptyTax = (): Partial<SalesTaxCharge> => ({ charge_type: '', account_head: '', tax_rate: 0, account_currency: DEFAULT_CURRENCY, included_in_print_rate: 0 });
|
|
|
|
const SalesOrderDetail: React.FC = () => {
|
|
const { soName } = useParams<{ soName: string }>();
|
|
const [searchParams] = useSearchParams();
|
|
const navigate = useNavigate();
|
|
const isNew = soName === 'new';
|
|
|
|
const contextProject = searchParams.get('project') || '';
|
|
const contextCustomer = searchParams.get('customer') || '';
|
|
const contextCompany = searchParams.get('company') || DEFAULT_COMPANY;
|
|
const incomingProject = searchParams.get('project') || '';
|
|
|
|
const [doc, setDoc] = useState<SalesOrder | null>(null);
|
|
const [loading, setLoading] = useState(!isNew);
|
|
const [saving, setSaving] = useState(false);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [isEditing, setIsEditing] = useState(isNew);
|
|
const [expandedItem, setExpandedItem] = useState<number | null>(null);
|
|
const [expandedTax, setExpandedTax] = useState<number | null>(null);
|
|
const projectFieldRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
const today = new Date().toISOString().split('T')[0];
|
|
|
|
const [form, setForm] = useState<Partial<SalesOrder>>({
|
|
customer: contextCustomer, customer_name: contextCustomer,
|
|
company: contextCompany, project: contextProject,
|
|
transaction_date: today, currency: DEFAULT_CURRENCY, order_type: 'Sales',
|
|
taxes_and_charges: DEFAULT_SALES_TAXES_TEMPLATE,
|
|
items: [], taxes: [],
|
|
} as any);
|
|
|
|
const syncForm = useCallback((d: SalesOrder) => {
|
|
setForm({
|
|
customer: d.customer || '', customer_name: d.customer_name || d.customer || '',
|
|
company: d.company || DEFAULT_COMPANY, project: d.project || incomingProject || '',
|
|
transaction_date: d.transaction_date || today,
|
|
currency: d.currency === 'INR' ? DEFAULT_CURRENCY : (d.currency || DEFAULT_CURRENCY),
|
|
order_type: d.order_type || 'Sales', cost_center: d.cost_center || '',
|
|
selling_price_list: (d as any).selling_price_list || '',
|
|
price_list_currency: (d as any).price_list_currency || '',
|
|
conversion_rate: (d as any).conversion_rate || 1,
|
|
plc_conversion_rate: (d as any).plc_conversion_rate || 1,
|
|
tax_category: (d as any).tax_category || '',
|
|
taxes_and_charges: (d as any).taxes_and_charges || '',
|
|
items: d.items || [], taxes: d.taxes || [],
|
|
} as any);
|
|
setExpandedItem(null); setExpandedTax(null);
|
|
}, [incomingProject, today]);
|
|
|
|
useEffect(() => {
|
|
if (isNew) return;
|
|
setLoading(true);
|
|
salesOrderService.getSalesOrder(soName!)
|
|
.then(d => { setDoc(d); syncForm(d); })
|
|
.catch(e => toast.error(formatFrappeApiError(e)))
|
|
.finally(() => setLoading(false));
|
|
}, [soName, isNew, syncForm]);
|
|
|
|
// When returning from creating a new Project, auto-link it into this Sales Order
|
|
// and persist it automatically for draft orders.
|
|
const [hasAutoLinkedProject, setHasAutoLinkedProject] = useState(false);
|
|
useEffect(() => {
|
|
if (!incomingProject) return;
|
|
setForm(f => ({ ...f, project: (f as any).project || incomingProject } as any));
|
|
}, [incomingProject]);
|
|
|
|
useEffect(() => {
|
|
if (!incomingProject) return;
|
|
if (isNew) return;
|
|
if (!doc) return;
|
|
if (hasAutoLinkedProject) return;
|
|
if (doc.docstatus !== 0) return;
|
|
const existing = String((doc as any).project || '').trim();
|
|
if (existing) return;
|
|
|
|
setHasAutoLinkedProject(true);
|
|
salesOrderService
|
|
.updateSalesOrder(soName!, { project: incomingProject } as any)
|
|
.then(updated => { setDoc(updated); syncForm(updated); })
|
|
.catch(e => toast.error(formatFrappeApiError(e)));
|
|
}, [doc, hasAutoLinkedProject, incomingProject, isNew, soName, syncForm]);
|
|
|
|
// Auto-fetch company default currency for new documents
|
|
useEffect(() => {
|
|
const company = (form as any).company;
|
|
if (!isNew || !company) return;
|
|
fetch(`/api/resource/Company/${encodeURIComponent(company)}`, { credentials: 'include' })
|
|
.then(r => r.json()).then(b => {
|
|
if (b.data?.default_currency) {
|
|
const cur = displayTxnCurrency(b.data.default_currency);
|
|
setForm(f => ({ ...f, currency: cur,
|
|
selling_price_list: (f as any).selling_price_list || 'Standard Selling',
|
|
price_list_currency: (f as any).price_list_currency || cur,
|
|
} as any));
|
|
}
|
|
}).catch(() => {});
|
|
}, [(form as any).company, isNew]);
|
|
|
|
const set = (k: keyof SalesOrder, v: any) => setForm(f => ({ ...f, [k]: v }));
|
|
|
|
const goToProjectFromSO = useCallback(() => {
|
|
const p = (form as any).project;
|
|
if (p && String(p).trim()) {
|
|
navigate(`/projects/list/${encodeURIComponent(String(p))}`);
|
|
return;
|
|
}
|
|
// Create a new project and keep user on the Project page.
|
|
const params = new URLSearchParams();
|
|
if (!isNew && soName) params.set('source_so', String(soName));
|
|
if (form.customer) params.set('customer', String(form.customer));
|
|
if ((form as any).company) params.set('company', String((form as any).company));
|
|
navigate(`/projects/list/new?${params.toString()}`);
|
|
}, [form, isNew, navigate, soName]);
|
|
|
|
// ── Item helpers ──────────────────────────────────────────────────────────
|
|
const updateItem = (idx: number, k: keyof SalesOrderItem, v: any) =>
|
|
setForm(f => {
|
|
const items = [...(f.items || [])];
|
|
const updated = { ...items[idx], [k]: v };
|
|
if (k === 'qty' || k === 'rate') {
|
|
const qty = parseFloat(String(k === 'qty' ? v : updated.qty)) || 0;
|
|
const rate = parseFloat(String(k === 'rate' ? v : updated.rate)) || 0;
|
|
updated.amount = parseFloat((qty * rate).toFixed(4));
|
|
}
|
|
items[idx] = updated;
|
|
return { ...f, items };
|
|
});
|
|
|
|
const handleItemCode = async (idx: number, code: string) => {
|
|
updateItem(idx, 'item_code', code);
|
|
if (!code) return;
|
|
try {
|
|
const r = await fetch(`/api/resource/Item/${encodeURIComponent(code)}`, { credentials: 'include' });
|
|
const body = await r.json(); const d = body.data; if (!d) return;
|
|
setForm(f => {
|
|
const items = [...(f.items || [])];
|
|
items[idx] = { ...items[idx], item_code: code, item_name: d.item_name || code, description: d.description || d.item_name || code, stock_uom: d.stock_uom || '', uom: d.sales_uom || d.stock_uom || '', price_list_rate: d.standard_rate ?? 0, rate: items[idx].rate || d.standard_rate || 0 };
|
|
return { ...f, items };
|
|
});
|
|
} catch { /* ignore */ }
|
|
};
|
|
|
|
const addItem = (afterIdx?: number) => {
|
|
setForm(f => {
|
|
const items = [...(f.items || [])];
|
|
const newItem = emptyItem();
|
|
let newIdx: number;
|
|
if (afterIdx !== undefined) { items.splice(afterIdx + 1, 0, newItem); newIdx = afterIdx + 1; }
|
|
else { items.push(newItem); newIdx = items.length - 1; }
|
|
setTimeout(() => setExpandedItem(newIdx), 0);
|
|
return { ...f, items };
|
|
});
|
|
};
|
|
const removeItem = (idx: number) => { setForm(f => { const items = [...(f.items || [])]; items.splice(idx, 1); return { ...f, items }; }); setExpandedItem(null); };
|
|
|
|
// ── Tax helpers ───────────────────────────────────────────────────────────
|
|
const updateTax = (idx: number, k: keyof SalesTaxCharge, v: any) =>
|
|
setForm(f => { const taxes = [...(f.taxes || [])]; taxes[idx] = { ...taxes[idx], [k]: v }; return { ...f, taxes }; });
|
|
|
|
const addTax = (afterIdx?: number) => {
|
|
setForm(f => {
|
|
const taxes = [...(f.taxes || [])];
|
|
const newTax = emptyTax();
|
|
let newIdx: number;
|
|
if (afterIdx !== undefined) { taxes.splice(afterIdx + 1, 0, newTax); newIdx = afterIdx + 1; }
|
|
else { taxes.push(newTax); newIdx = taxes.length - 1; }
|
|
setTimeout(() => setExpandedTax(newIdx), 0);
|
|
return { ...f, taxes };
|
|
});
|
|
};
|
|
const removeTax = (idx: number) => { setForm(f => { const taxes = [...(f.taxes || [])]; taxes.splice(idx, 1); return { ...f, taxes }; }); setExpandedTax(null); };
|
|
|
|
// ── Computed ──────────────────────────────────────────────────────────────
|
|
const netTotal = (form.items || []).reduce((s, it) => s + ((it.qty || 0) * (it.rate || 0)), 0);
|
|
|
|
// Calculate each tax row amount based on charge_type and tax_rate
|
|
const computeTaxRows = (taxes: any[], baseNet: number) => {
|
|
let runningTotal = baseNet;
|
|
return taxes.map(tx => {
|
|
const pct = taxRatePercent(tx);
|
|
let txAmt = 0;
|
|
if (tx.charge_type === 'On Net Total') txAmt = baseNet * (pct / 100);
|
|
else if (tx.charge_type === 'Actual') txAmt = tx.tax_amount || 0;
|
|
else if (tx.charge_type === 'On Previous Row Amount' || tx.charge_type === 'On Previous Row Total') txAmt = runningTotal * (pct / 100);
|
|
else txAmt = baseNet * (pct / 100);
|
|
runningTotal += txAmt;
|
|
return { ...tx, _computed_amt: txAmt, _computed_total: runningTotal };
|
|
});
|
|
};
|
|
const computedTaxes = computeTaxRows(form.taxes || [], netTotal);
|
|
const taxTotal = computedTaxes.reduce((s, tx) => s + tx._computed_amt, 0);
|
|
const grandTotal = netTotal + taxTotal;
|
|
|
|
// ── Payload ───────────────────────────────────────────────────────────────
|
|
const loadTaxTemplate = async (templateName: string) => {
|
|
if (!templateName) return;
|
|
try {
|
|
const r = await fetch(`/api/resource/Sales Taxes and Charges Template/${encodeURIComponent(templateName)}`, { credentials: 'include' });
|
|
const body = await r.json();
|
|
const tmpl = body.data;
|
|
if (tmpl?.taxes?.length) {
|
|
setForm(f => ({ ...f, taxes: tmpl.taxes.map((tx: any) => ({
|
|
charge_type: tx.charge_type, account_head: tx.account_head,
|
|
description: tx.description, tax_rate: tx.rate ?? tx.tax_rate ?? 0,
|
|
cost_center: tx.cost_center, account_currency: tx.account_currency,
|
|
included_in_print_rate: tx.included_in_print_rate ?? 0,
|
|
})) }));
|
|
}
|
|
} catch { /* ignore */ }
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!isNew) return;
|
|
void loadTaxTemplate(DEFAULT_SALES_TAXES_TEMPLATE);
|
|
}, [isNew]);
|
|
|
|
const buildPayload = (): Partial<SalesOrder> => ({
|
|
customer: form.customer, company: form.company || undefined,
|
|
project: form.project || undefined, cost_center: (form as any).cost_center || undefined,
|
|
transaction_date: form.transaction_date, currency: form.currency || undefined,
|
|
order_type: form.order_type || 'Sales',
|
|
selling_price_list: (form as any).selling_price_list || 'Standard Selling',
|
|
price_list_currency: (form as any).price_list_currency || form.currency || undefined,
|
|
conversion_rate: (form as any).conversion_rate || 1,
|
|
plc_conversion_rate: (form as any).plc_conversion_rate || 1,
|
|
tax_category: (form as any).tax_category || undefined,
|
|
taxes_and_charges: (form as any).taxes_and_charges || undefined,
|
|
items: (form.items || []).filter(it => it.item_code).map((it, i) => ({
|
|
item_code: it.item_code, item_name: it.item_name || it.item_code,
|
|
description: it.description || it.item_name || it.item_code,
|
|
qty: it.qty ?? 1, uom: it.uom || undefined, stock_uom: it.stock_uom || undefined,
|
|
conversion_factor: it.conversion_factor ?? 1,
|
|
rate: it.rate ?? 0, amount: (it.qty || 0) * (it.rate || 0),
|
|
price_list_rate: it.price_list_rate ?? 0,
|
|
delivery_date: it.delivery_date || undefined,
|
|
item_tax_template: it.item_tax_template || undefined,
|
|
is_free_item: it.is_free_item ?? 0, grant_commission: it.grant_commission ?? 0,
|
|
delivered_by_supplier: it.delivered_by_supplier ?? 0,
|
|
supplier: it.supplier || undefined,
|
|
weight_per_unit: it.weight_per_unit || undefined,
|
|
weight_uom: it.weight_uom || undefined,
|
|
delivery_warehouse: it.delivery_warehouse || undefined,
|
|
against_blanket_order: it.against_blanket_order ?? 0,
|
|
ensure_delivery_based_on_produced_serial_no: it.ensure_delivery_based_on_produced_serial_no ?? 0,
|
|
bom_no: it.bom_no || undefined,
|
|
project: form.project || undefined, cost_center: (form as any).cost_center || undefined, idx: i + 1,
|
|
})),
|
|
taxes: (form.taxes || []).filter(tx => tx.charge_type).map((tx, i) => ({
|
|
charge_type: tx.charge_type, account_head: tx.account_head || undefined,
|
|
description: tx.description || undefined,
|
|
included_in_print_rate: tx.included_in_print_rate ?? 0,
|
|
cost_center: tx.cost_center || undefined, rate: taxRatePercent(tx),
|
|
account_currency: tx.account_currency || DEFAULT_CURRENCY, idx: i + 1,
|
|
})),
|
|
});
|
|
|
|
const handleSave = async () => {
|
|
if (!form.customer) { toast.error('Customer is required'); return; }
|
|
try {
|
|
setSaving(true);
|
|
if (isNew) {
|
|
const created = await salesOrderService.createSalesOrder(buildPayload());
|
|
toast.success('Sales Order created');
|
|
setIsEditing(false);
|
|
navigate(`/sales-orders/${created.name}`);
|
|
} else {
|
|
const updated = await salesOrderService.updateSalesOrder(soName!, buildPayload());
|
|
setDoc(updated); syncForm(updated);
|
|
toast.success('Sales Order saved');
|
|
setIsEditing(false);
|
|
}
|
|
} catch (e: any) { toast.error(formatFrappeApiError(e) || 'Error saving'); }
|
|
finally { setSaving(false); }
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
if (!soName || isNew) return;
|
|
try {
|
|
setSubmitting(true);
|
|
const updated = await salesOrderService.submitSalesOrder(soName);
|
|
setDoc(updated); syncForm(updated);
|
|
toast.success('Sales Order submitted');
|
|
} catch (e: any) { toast.error(formatFrappeApiError(e) || 'Error submitting'); }
|
|
finally { setSubmitting(false); }
|
|
};
|
|
|
|
const ctxParams = () => {
|
|
const p = new URLSearchParams();
|
|
if (form.customer) p.set('customer', form.customer);
|
|
if (form.company) p.set('company', String(form.company || ''));
|
|
if (form.project) p.set('project', String(form.project || ''));
|
|
return p;
|
|
};
|
|
|
|
const createDN = () => {
|
|
const p = ctxParams(); p.set('so', soName!);
|
|
navigate(`/delivery-notes/new?${p.toString()}`);
|
|
};
|
|
const createSI = () => {
|
|
const p = ctxParams();
|
|
p.set('so', soName!);
|
|
navigate(`/invoices/new?${p.toString()}`);
|
|
};
|
|
const createMR = () => {
|
|
const p = new URLSearchParams();
|
|
if (form.project) p.set('project', String(form.project));
|
|
if (form.company) p.set('company', String(form.company || ''));
|
|
navigate(`/material-requests/new?${p.toString()}`);
|
|
};
|
|
|
|
const editable = isNew || isEditing;
|
|
const isSubmitted = !isNew && doc?.docstatus === 1;
|
|
const title = isNew ? 'New Sales Order' : (form.customer_name || soName || '');
|
|
|
|
if (loading) return <div className="flex items-center justify-center min-h-[400px]"><FaSpinner className="animate-spin text-blue-500 text-3xl" /></div>;
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 px-6 py-6">
|
|
<ToastContainer position="top-right" autoClose={3500} />
|
|
|
|
<div className="flex items-center gap-2 text-sm mb-6 text-gray-500">
|
|
<button type="button" onClick={() => navigate('/projects')} className="hover:text-blue-600">Project Management</button>
|
|
<span>/</span>
|
|
<button type="button" onClick={() => navigate('/sales-orders')} className="hover:text-blue-600">Sales Orders</button>
|
|
<span>/</span>
|
|
<span className="text-gray-700 dark:text-gray-300">{isNew ? 'New Sales Order' : soName}</span>
|
|
</div>
|
|
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
|
|
|
{/* Header — shows customer name as title */}
|
|
<div className="px-6 py-4 border-b border-gray-100 dark:border-gray-700 flex items-center justify-between gap-4 flex-wrap">
|
|
<div className="flex items-center gap-3">
|
|
<button onClick={() => navigate('/sales-orders')} className="text-gray-400 hover:text-gray-700"><FaArrowLeft /></button>
|
|
<FaShoppingCart className="text-blue-500" />
|
|
<div>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white">{title}</h1>
|
|
{!isNew && soName && (
|
|
<span className="text-xs text-gray-400 font-mono">{soName}</span>
|
|
)}
|
|
{!isNew && (
|
|
<span className={`px-2 py-0.5 rounded text-xs font-semibold ${(() => { const s = doc?.status || ''; if (doc?.docstatus === 2 || s === 'Cancelled') return 'bg-red-100 text-red-700'; if (!doc || doc.docstatus === 0) return 'bg-yellow-100 text-yellow-800'; if (s === 'Completed') return 'bg-green-100 text-green-800'; if (s === 'To Deliver and Bill' || s === 'To Bill' || s === 'To Deliver') return 'bg-blue-100 text-blue-800'; if (s === 'On Hold') return 'bg-orange-100 text-orange-800'; if (s === 'Closed') return 'bg-gray-100 text-gray-700'; return 'bg-green-100 text-green-800'; })()}`}>
|
|
{doc?.docstatus === 2 ? 'Cancelled' : doc?.docstatus === 0 ? 'Draft' : (doc?.status || 'Submitted')}
|
|
</span>
|
|
)}
|
|
{(form as any).project && (
|
|
<button
|
|
type="button"
|
|
onClick={() => navigate(`/projects/list/${encodeURIComponent(String((form as any).project))}`)}
|
|
className="text-xs text-blue-700 dark:text-blue-400 border border-blue-200 dark:border-blue-700 rounded-full px-2.5 py-0.5 hover:underline"
|
|
>
|
|
{(form as any).project}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<button
|
|
type="button"
|
|
onClick={goToProjectFromSO}
|
|
title={(form as any).project ? 'Open linked project' : 'Link a project (scrolls to Project field)'}
|
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium shadow-sm"
|
|
>
|
|
<FaPlus size={10} />
|
|
<FaFolderOpen size={13} />
|
|
Project
|
|
</button>
|
|
{/* Create dropdown — only when submitted */}
|
|
{isSubmitted && (
|
|
<CreateDropdown items={[
|
|
{ label: 'Delivery Note', icon: <FaTruck size={13} />, onClick: createDN },
|
|
{ label: 'Sales Invoice', icon: <FaFileInvoiceDollar size={13} />, onClick: createSI },
|
|
{ label: 'Material Request', icon: <FaBoxes size={13} />, onClick: createMR },
|
|
]} />
|
|
)}
|
|
{!isNew && !isEditing && doc?.docstatus === 0 && (
|
|
<button onClick={handleSubmit} disabled={submitting} className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 text-sm font-medium">
|
|
{submitting ? <FaSpinner className="animate-spin" /> : <FaPaperPlane size={12} />} Submit
|
|
</button>
|
|
)}
|
|
{!isNew && !isEditing && !isSubmitted && (
|
|
<button type="button" onClick={() => setIsEditing(true)} className="flex items-center gap-2 px-4 py-2 border border-blue-500 text-blue-600 rounded-lg hover:bg-blue-50 text-sm">
|
|
<FaEdit /> Edit
|
|
</button>
|
|
)}
|
|
{editable && (
|
|
<>
|
|
<button type="button" onClick={handleSave} disabled={saving} className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 text-sm font-medium">
|
|
{saving ? <FaSpinner className="animate-spin" /> : <FaSave />}{saving ? 'Saving…' : 'Save'}
|
|
</button>
|
|
{!isNew && <button onClick={() => { if (doc) syncForm(doc); setIsEditing(false); }} className="px-3 py-2 border border-gray-300 rounded-lg text-gray-600 text-sm"><FaTimes /></button>}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main fields */}
|
|
<div className="px-6 pt-5 pb-4">
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-4">
|
|
<div><FL required>Customer</FL>
|
|
{editable ? <LinkField label="Customer" hideLabel doctype="Customer" value={form.customer || ''} onChange={v => { set('customer', v); set('customer_name', v); }} placeholder="Select customer…" /> : <RV>{form.customer}</RV>}
|
|
</div>
|
|
<div><FL required>Transaction Date</FL>
|
|
{editable ? <input type="date" value={form.transaction_date || ''} onChange={e => set('transaction_date', e.target.value)} className={inputCls} /> : <RV>{form.transaction_date}</RV>}
|
|
</div>
|
|
<div><FL>Company</FL>
|
|
{editable ? <LinkField label="Company" hideLabel doctype="Company" value={(form as any).company || ''} onChange={v => set('company' as any, v)} placeholder="Select company…" /> : <RV>{(form as any).company}</RV>}
|
|
</div>
|
|
<div ref={projectFieldRef} className="scroll-mt-28">
|
|
<FL>Project</FL>
|
|
<RV>
|
|
{(form as any).project
|
|
? <button
|
|
type="button"
|
|
onClick={() => navigate(`/projects/list/${encodeURIComponent(String((form as any).project))}`)}
|
|
className="text-blue-700 hover:underline font-medium"
|
|
title="Open project"
|
|
>
|
|
{(form as any).project}
|
|
</button>
|
|
: <button
|
|
type="button"
|
|
onClick={goToProjectFromSO}
|
|
className="text-blue-700 hover:underline font-medium"
|
|
title="Create / link a project"
|
|
>
|
|
—
|
|
</button>}
|
|
</RV>
|
|
</div>
|
|
<div><FL>Currency</FL>
|
|
{editable
|
|
? <select value={form.currency || DEFAULT_CURRENCY} onChange={e => set('currency', e.target.value)} className={inputCls}>
|
|
<option value="SAR">SAR</option><option value="USD">USD</option><option value="EUR">EUR</option>
|
|
</select>
|
|
: <RV>{form.currency}</RV>}
|
|
</div>
|
|
<div><FL>Cost Center</FL>
|
|
{editable ? <LinkField label="Cost Center" hideLabel doctype="Cost Center" value={(form as any).cost_center || ''} onChange={v => set('cost_center' as any, v)} placeholder="Cost center…" /> : <RV>{(form as any).cost_center}</RV>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Items ── */}
|
|
<div className="border-t border-gray-100 dark:border-gray-700">
|
|
<div className="px-6 py-3 bg-gray-50 dark:bg-gray-900/20 border-b border-gray-100 dark:border-gray-700">
|
|
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Items</span>
|
|
</div>
|
|
<div className="px-6 pb-5">
|
|
<div className="overflow-x-auto -mx-2 mt-3">
|
|
<table className="min-w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-8">No.</th>
|
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 min-w-[180px]">Item Code <span className="text-red-400">*</span></th>
|
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-32">Delivery Date <span className="text-red-400">*</span></th>
|
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-24">Quantity <span className="text-red-400">*</span></th>
|
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Rate ({displayTxnCurrency(form.currency)})</th>
|
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Amount ({displayTxnCurrency(form.currency)})</th>
|
|
{editable && <th className="w-16 py-2 px-2" />}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{(form.items || []).map((it, idx) => (
|
|
<React.Fragment key={idx}>
|
|
<tr className={`border-b border-gray-100 dark:border-gray-700 align-middle ${expandedItem === idx ? 'bg-blue-50/60 dark:bg-blue-900/10' : ''}`}>
|
|
<td className="py-1.5 px-3 text-gray-400 text-xs">{idx + 1}</td>
|
|
{/* Item Code — LinkField inline */}
|
|
<td className="py-1.5 px-2 min-w-[180px]">
|
|
{editable
|
|
? <LinkField label="Item" hideLabel doctype="Item" value={it.item_code || ''} onChange={v => handleItemCode(idx, v)} placeholder="Item Code" />
|
|
: <span className="font-medium text-gray-800 dark:text-gray-200">{it.item_code || '-'}</span>}
|
|
</td>
|
|
{/* Delivery Date — inline */}
|
|
<td className="py-1.5 px-2 w-32">
|
|
{editable
|
|
? <input type="date" value={it.delivery_date || ''} onChange={e => updateItem(idx, 'delivery_date', e.target.value)} className={inlineTxt} />
|
|
: <span className="text-gray-500 text-sm">{it.delivery_date || '-'}</span>}
|
|
</td>
|
|
{/* Qty — inline */}
|
|
<td className="py-1.5 px-2 w-24">
|
|
{editable ? <input type="number" min={0} step="1" value={it.qty ?? 0} onChange={e => updateItem(idx, 'qty', parseFloat(e.target.value) || 0)} className={inlineNum} /> : <span className="block text-right text-gray-700 dark:text-gray-300 text-sm pr-1">{it.qty ?? 0}</span>}
|
|
</td>
|
|
{/* Rate — inline */}
|
|
<td className="py-1.5 px-2 w-28">
|
|
{editable ? <input type="number" min={0} step="0.01" value={it.rate ?? 0} onChange={e => updateItem(idx, 'rate', parseFloat(e.target.value) || 0)} className={inlineNum} /> : <span className="block text-right text-gray-700 dark:text-gray-300 text-sm pr-1">{(it.rate ?? 0).toFixed(2)}</span>}
|
|
</td>
|
|
{/* Amount — auto-calc */}
|
|
<td className="py-1.5 px-3 text-right font-semibold text-gray-900 dark:text-white text-sm">{((it.qty || 0) * (it.rate || 0)).toFixed(2)}</td>
|
|
{editable && (
|
|
<td className="py-1.5 px-2">
|
|
<div className="flex items-center gap-1">
|
|
<button onClick={() => setExpandedItem(expandedItem === idx ? null : idx)}
|
|
className={`p-1.5 rounded text-xs ${expandedItem === idx ? 'bg-blue-600 text-white' : 'text-blue-600 hover:bg-blue-50'}`} title="More fields">
|
|
<FaPencilAlt size={11} />
|
|
</button>
|
|
<button onClick={() => removeItem(idx)} className="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-50 rounded"><FaTrash size={11} /></button>
|
|
</div>
|
|
</td>
|
|
)}
|
|
</tr>
|
|
{editable && expandedItem === idx && (
|
|
<SOItemRowEditor item={it} rowNo={idx + 1}
|
|
onChange={(k, v) => { if (k === 'item_code') handleItemCode(idx, v as string); else updateItem(idx, k, v); }}
|
|
onClose={() => setExpandedItem(null)} onDelete={() => removeItem(idx)} onInsertBelow={() => addItem(idx)} />
|
|
)}
|
|
</React.Fragment>
|
|
))}
|
|
{editable && (
|
|
<tr><td colSpan={7} className="py-2 px-3">
|
|
<button type="button" onClick={() => addItem()} className="flex items-center gap-1.5 text-blue-600 hover:text-blue-700 text-sm font-medium"><FaPlus size={10} /> Add Row</button>
|
|
</td></tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div className="mt-3 flex justify-between text-sm border-t border-gray-100 dark:border-gray-700 pt-3">
|
|
<span className="text-gray-500">Total Qty: <strong>{(form.items || []).reduce((s, it) => s + (it.qty || 0), 0)}</strong></span>
|
|
<span className="text-gray-500">Total: <strong className="text-gray-900 dark:text-white">{displayTxnCurrency(form.currency)} {netTotal.toFixed(2)}</strong></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Taxes ── */}
|
|
<div className="border-t border-gray-100 dark:border-gray-700">
|
|
<div className="px-6 py-3 bg-gray-50 dark:bg-gray-900/20 border-b border-gray-100 dark:border-gray-700">
|
|
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Taxes</span>
|
|
</div>
|
|
<div className="px-6 pt-4 pb-2 grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3">
|
|
<div><FL>Tax Category</FL>
|
|
{editable ? <LinkField label="Tax Category" hideLabel doctype="Tax Category" value={(form as any).tax_category || ''} onChange={v => set('tax_category' as any, v)} placeholder="Select tax category…" /> : <RV>{(form as any).tax_category}</RV>}
|
|
</div>
|
|
<div><FL>Sales Taxes and Charges Template</FL>
|
|
{editable
|
|
? <LinkField label="Sales Taxes and Charges Template" hideLabel doctype="Sales Taxes and Charges Template" value={(form as any).taxes_and_charges || ''} onChange={v => { set('taxes_and_charges' as any, v); loadTaxTemplate(v); }} placeholder="Select template…" />
|
|
: <RV>{(form as any).taxes_and_charges}</RV>}
|
|
</div>
|
|
</div>
|
|
<div className="px-6 pb-5">
|
|
<div className="overflow-x-auto -mx-2 mt-3">
|
|
<table className="min-w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-8">No.</th>
|
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-44">Type <span className="text-red-400">*</span></th>
|
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3">Account Head <span className="text-red-400">*</span></th>
|
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-24">Tax Rate</th>
|
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Amount</th>
|
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Total</th>
|
|
{editable && <th className="w-16 py-2 px-2" />}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{computedTaxes.map((tx, idx) => (
|
|
<React.Fragment key={idx}>
|
|
<tr className={`border-b border-gray-100 dark:border-gray-700 align-middle ${expandedTax === idx ? 'bg-blue-50/60 dark:bg-blue-900/10' : ''}`}>
|
|
<td className="py-1.5 px-3 text-gray-400 text-xs">{idx + 1}</td>
|
|
{/* Type — inline select */}
|
|
<td className="py-1.5 px-2 w-44">
|
|
{editable
|
|
? <select value={tx.charge_type || ''} onChange={e => updateTax(idx, 'charge_type', e.target.value)} className={inlineTxt}>
|
|
<option value="">Select type…</option>
|
|
<option value="Actual">Actual</option>
|
|
<option value="On Net Total">On Net Total</option>
|
|
<option value="On Previous Row Amount">On Previous Row Amount</option>
|
|
<option value="On Previous Row Total">On Previous Row Total</option>
|
|
<option value="On Item Quantity">On Item Quantity</option>
|
|
<option value="Inter Company Transaction">Inter Company Transaction</option>
|
|
</select>
|
|
: <span className="text-gray-700 dark:text-gray-300">{tx.charge_type || '-'}</span>}
|
|
</td>
|
|
{/* Account Head — LinkField inline */}
|
|
<td className="py-1.5 px-2">
|
|
{editable
|
|
? <LinkField label="Account Head" hideLabel doctype="Account" value={tx.account_head || ''} onChange={v => updateTax(idx, 'account_head', v)} placeholder="Account Head" />
|
|
: <span className="text-gray-700 dark:text-gray-300">{tx.account_head || '-'}</span>}
|
|
</td>
|
|
{/* Tax Rate — inline */}
|
|
<td className="py-1.5 px-2 w-24">
|
|
{editable
|
|
? <input type="number" min={0} step="0.01" value={taxRatePercent(tx)} onChange={e => updateTax(idx, 'tax_rate', parseFloat(e.target.value) || 0)} className={inlineNum} />
|
|
: <span className="block text-right text-gray-700 dark:text-gray-300 pr-1">{taxRatePercent(tx)}</span>}
|
|
</td>
|
|
<td className="py-1.5 px-3 text-right text-gray-700 dark:text-gray-300 text-sm">{(tx._computed_amt ?? 0).toFixed(2)}</td>
|
|
<td className="py-1.5 px-3 text-right font-semibold text-gray-900 dark:text-white text-sm">{(tx._computed_total ?? 0).toFixed(2)}</td>
|
|
{editable && (
|
|
<td className="py-1.5 px-2">
|
|
<div className="flex items-center gap-1">
|
|
<button onClick={() => setExpandedTax(expandedTax === idx ? null : idx)}
|
|
className={`p-1.5 rounded text-xs ${expandedTax === idx ? 'bg-blue-600 text-white' : 'text-blue-600 hover:bg-blue-50'}`} title="More fields">
|
|
<FaPencilAlt size={11} />
|
|
</button>
|
|
<button onClick={() => removeTax(idx)} className="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-50 rounded"><FaTrash size={11} /></button>
|
|
</div>
|
|
</td>
|
|
)}
|
|
</tr>
|
|
{editable && expandedTax === idx && (
|
|
<SOTaxRowEditor tax={tx} rowNo={idx + 1} onChange={(k, v) => updateTax(idx, k, v)}
|
|
onClose={() => setExpandedTax(null)} onDelete={() => removeTax(idx)} onInsertBelow={() => addTax(idx)} />
|
|
)}
|
|
</React.Fragment>
|
|
))}
|
|
{editable && (
|
|
<tr><td colSpan={7} className="py-2 px-3">
|
|
<button type="button" onClick={() => addTax()} className="flex items-center gap-1.5 text-blue-600 hover:text-blue-700 text-sm font-medium"><FaPlus size={10} /> Add Row</button>
|
|
</td></tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{(form.taxes || []).length > 0 && (
|
|
<div className="mt-2 flex justify-end text-sm text-gray-500 pt-2 border-t border-gray-100 dark:border-gray-700">
|
|
Total Taxes and Charges:{' '}
|
|
<strong className="ml-2 text-gray-900 dark:text-white">
|
|
{displayTxnCurrency(form.currency)} {(doc?.total_taxes_and_charges ?? taxTotal).toFixed(2)}
|
|
</strong>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Totals ── */}
|
|
<div className="border-t border-gray-100 dark:border-gray-700 px-6 py-4">
|
|
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Totals</h3>
|
|
<div className="space-y-2 max-w-xs ml-auto">
|
|
{[
|
|
{ label: 'Net Total', value: (doc?.net_total ?? netTotal).toFixed(2) },
|
|
{ label: 'Total Taxes', value: (doc?.total_taxes_and_charges ?? taxTotal).toFixed(2) },
|
|
{ label: 'Grand Total', value: (doc?.grand_total ?? grandTotal).toFixed(2) },
|
|
{ label: 'Rounding Adjustment', value: (doc?.rounding_adjustment ?? 0).toFixed(2) },
|
|
{ label: 'Rounded Total', value: (doc?.rounded_total ?? grandTotal).toFixed(2) },
|
|
{ label: 'Advance Paid', value: (doc?.advance_paid ?? 0).toFixed(2) },
|
|
].map(({ label, value }) => (
|
|
<div key={label} className="flex justify-between text-sm border-b border-gray-100 dark:border-gray-700 pb-1.5 last:border-0">
|
|
<span className="text-gray-500">{label}</span>
|
|
<span className="font-semibold text-gray-900 dark:text-white">{displayTxnCurrency(form.currency)} {value}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Meta */}
|
|
{!isNew && doc && (
|
|
<div className="border-t border-gray-100 dark:border-gray-700 px-6 py-4 grid grid-cols-3 gap-4 text-sm bg-gray-50 dark:bg-gray-900/20">
|
|
<div><FL>Created By</FL><RV>{doc.owner}</RV></div>
|
|
<div><FL>Created</FL><RV>{doc.creation ? new Date(doc.creation).toLocaleString() : '-'}</RV></div>
|
|
<div><FL>Modified</FL><RV>{doc.modified ? new Date(doc.modified).toLocaleString() : '-'}</RV></div>
|
|
</div>
|
|
)}
|
|
|
|
{!isNew && (
|
|
<ActivityLog
|
|
doctype="Sales Order"
|
|
docname={doc?.name || soName || ''}
|
|
creationDate={doc?.creation}
|
|
createdBy={doc?.owner}
|
|
compact={false}
|
|
initialVisible={5}
|
|
collapsible
|
|
startCollapsed
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default SalesOrderDetail;
|