build ui for adding and displaying added items
This commit is contained in:
@@ -1,3 +0,0 @@
|
|||||||
export default function App() {
|
|
||||||
return <h1 className="text-3xl font-bold text-blue-600 p-4">Bill Generator</h1>
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { useBill } from "../hooks";
|
||||||
|
import { BillItemRow } from "./BillItemRow";
|
||||||
|
import { ItemForm } from "./ItemForm";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const {
|
||||||
|
bill,
|
||||||
|
computedBill,
|
||||||
|
updateTitle,
|
||||||
|
updateCurrency,
|
||||||
|
updateTax,
|
||||||
|
addItem,
|
||||||
|
removeItem,
|
||||||
|
resetBill,
|
||||||
|
} = useBill();
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<div className="max-w-md mx-auto">
|
||||||
|
<div className="px-4 py-4 flex flex-col gap-4">
|
||||||
|
<ItemForm currency={bill.currency} onAdd={addItem} />
|
||||||
|
{computedBill.items.length > 0 && (
|
||||||
|
<div className="bg-white rounded-2xl px-4 shadow-sm">
|
||||||
|
{computedBill.items.map((item) => (
|
||||||
|
<BillItemRow
|
||||||
|
key={item.id}
|
||||||
|
currency={bill.currency}
|
||||||
|
item={item}
|
||||||
|
onRemove={removeItem}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{computedBill.items.length > 0 && (
|
||||||
|
<button
|
||||||
|
className="w-full border border-gray-200 text-gray-400 hover:text-red-400 hover:border-red-200 py-3 rounded-xl text-sm transition-all cursor-pointer"
|
||||||
|
onClick={resetBill}
|
||||||
|
>
|
||||||
|
Clear bill
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { ComputedBillItem } from "../types";
|
||||||
|
import { formatCurrency } from "../utils";
|
||||||
|
|
||||||
|
interface BillItemRowProps {
|
||||||
|
item: ComputedBillItem;
|
||||||
|
currency: string;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BillItemRow({ item, currency, onRemove }: BillItemRowProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-100 last:border-0 py-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-800 truncate">
|
||||||
|
{item.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">
|
||||||
|
{item.quantity} ☓ {formatCurrency(item.unitPrice, currency)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 ml-3">
|
||||||
|
<span className="text-sm font-semibold text-gray-800">
|
||||||
|
{formatCurrency(item.finalPrice, currency)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="text-gray-300 transition-colors hover:text-red-400 text-large leading-none"
|
||||||
|
onClick={() => onRemove(item.id)}
|
||||||
|
>
|
||||||
|
☓
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { BillItemForm } from "../types";
|
||||||
|
|
||||||
|
interface ItemFormProps {
|
||||||
|
currency: string;
|
||||||
|
onAdd: (form: BillItemForm) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: BillItemForm = {
|
||||||
|
name: "",
|
||||||
|
quantity: 1,
|
||||||
|
unitPrice: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ItemForm({ currency, onAdd }: ItemFormProps) {
|
||||||
|
const [form, setForm] = useState<BillItemForm>(emptyForm);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
setError("Item name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.quantity <= 0) {
|
||||||
|
setError("Quantity must be greater than 0");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.unitPrice <= 0) {
|
||||||
|
setError("Price must be greater than 0");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onAdd(form);
|
||||||
|
setForm(emptyForm);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||||
|
Add Item
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Item name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((prev) => ({ ...prev, name: e.target.value }))
|
||||||
|
}
|
||||||
|
className="w-full border border-gray-200 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="Qty"
|
||||||
|
value={form.quantity}
|
||||||
|
min={1}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((prev) => ({ ...prev, quantity: Number(e.target.value) }))
|
||||||
|
}
|
||||||
|
className="w-1/3 border border-gray-200 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder={`Unit price (${currency})`}
|
||||||
|
value={form.unitPrice}
|
||||||
|
min={0}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
unitPrice: Number(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="w-2/3 border border-gray-200 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-red-500 text-xs px-1">{error}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="w-full bg-indigo-500 hover:bg-indigo-600 active:scale-95 text-white font-semibold py-3 rounded-xl text-sm transition-all"
|
||||||
|
>
|
||||||
|
+ Add Item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./useBill";
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { Currency, TaxType } from "../types";
|
||||||
|
import type { Bill, BillItem, BillItemForm, ComputedBill } from "../types";
|
||||||
|
import { computeBill, generateId } from "../utils";
|
||||||
|
|
||||||
|
export function useBill() {
|
||||||
|
const [bill, setBill] = useState<Bill>({
|
||||||
|
id: generateId(),
|
||||||
|
title: "New Bill",
|
||||||
|
currency: Currency.INR,
|
||||||
|
createdAt: new Date(),
|
||||||
|
taxPercent: 18,
|
||||||
|
taxType: TaxType.GST,
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const computedBill: ComputedBill = computeBill(bill);
|
||||||
|
|
||||||
|
const updateTitle = useCallback((title: string) => {
|
||||||
|
setBill((prev) => ({ ...prev, title }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateCurrency = useCallback((currency: Currency) => {
|
||||||
|
setBill((prev) => ({ ...prev, currency }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateTax = useCallback((taxType: TaxType, taxPercent: number) => {
|
||||||
|
setBill((prev) => ({ ...prev, taxType, taxPercent }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addItem = useCallback((form: BillItemForm) => {
|
||||||
|
const newItem: BillItem = {
|
||||||
|
...form,
|
||||||
|
id: generateId(),
|
||||||
|
};
|
||||||
|
setBill((prev) => ({ ...prev, items: [...prev.items, newItem] }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateItem = useCallback((id: string, form: BillItemForm) => {
|
||||||
|
setBill((prev) => ({
|
||||||
|
...prev,
|
||||||
|
items: prev.items.map((item) =>
|
||||||
|
item.id === id ? { ...item, ...form } : item,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeItem = useCallback((id: string) => {
|
||||||
|
setBill((prev) => ({
|
||||||
|
...prev,
|
||||||
|
items: prev.items.filter((item) => item.id !== id),
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetBill = useCallback(() => {
|
||||||
|
setBill({
|
||||||
|
id: generateId(),
|
||||||
|
title: "New Bill",
|
||||||
|
currency: Currency.INR,
|
||||||
|
createdAt: new Date(),
|
||||||
|
taxPercent: 18,
|
||||||
|
taxType: TaxType.GST,
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bill,
|
||||||
|
computedBill,
|
||||||
|
updateTitle,
|
||||||
|
updateCurrency,
|
||||||
|
updateTax,
|
||||||
|
addItem,
|
||||||
|
updateItem,
|
||||||
|
removeItem,
|
||||||
|
resetBill,
|
||||||
|
};
|
||||||
|
}
|
||||||
+6
-6
@@ -1,10 +1,10 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from "react-dom/client";
|
||||||
import './index.css'
|
import "./index.css";
|
||||||
import App from './App.tsx'
|
import App from "./components/App";
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
);
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
export interface BillItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
unitPrice: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum Currency {
|
||||||
|
INR = "INR",
|
||||||
|
USD = "USD",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TaxType {
|
||||||
|
None = "NONE",
|
||||||
|
GST = "GST",
|
||||||
|
VAT = "VAT",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Bill {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
currency: Currency;
|
||||||
|
taxType: TaxType;
|
||||||
|
taxPercent: number;
|
||||||
|
items: BillItem[];
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ComputedBillItem = BillItem & {
|
||||||
|
finalPrice: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// export interface ComputedBillItem extends BillItem {
|
||||||
|
// finalPrice: number;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export type ComputedBill = Bill & {
|
||||||
|
// subtotal: number;
|
||||||
|
// taxAmount: number;
|
||||||
|
// total: number;
|
||||||
|
// items: ComputedBillItem[];
|
||||||
|
// };
|
||||||
|
|
||||||
|
export interface ComputedBill extends Bill {
|
||||||
|
subtotal: number;
|
||||||
|
taxAmount: number;
|
||||||
|
total: number;
|
||||||
|
items: ComputedBillItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BillItemForm = Omit<BillItem, "id">;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { Bill, BillItem, ComputedBill, ComputedBillItem } from "../types";
|
||||||
|
|
||||||
|
export function findById<T extends { id: string }>(
|
||||||
|
items: T[],
|
||||||
|
id: string,
|
||||||
|
): T | undefined {
|
||||||
|
return items.find((item) => item.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeItem(item: BillItem): ComputedBillItem {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
finalPrice: item.unitPrice * item.quantity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeBill(bill: Bill): ComputedBill {
|
||||||
|
const computedItems = bill.items.map(computeItem);
|
||||||
|
|
||||||
|
const subtotal = computedItems.reduce(
|
||||||
|
(sum, item) => sum + item.finalPrice,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const taxAmount = (subtotal * bill.taxPercent) / 100;
|
||||||
|
const total = subtotal + taxAmount;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...bill,
|
||||||
|
items: computedItems,
|
||||||
|
subtotal,
|
||||||
|
taxAmount,
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCurrency(amount: number, currency: string): string {
|
||||||
|
return new Intl.NumberFormat("en-IN", {
|
||||||
|
style: "currency",
|
||||||
|
currency,
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateId(): string {
|
||||||
|
return Math.random().toString(36).slice(2, 9);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./calculator";
|
||||||
+1
-1
@@ -18,7 +18,7 @@
|
|||||||
/* Linting */
|
/* Linting */
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": false,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@
|
|||||||
/* Linting */
|
/* Linting */
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": false,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": ["vite.config.ts"]
|
||||||
|
|||||||
Reference in New Issue
Block a user