diff --git a/api/expenses-router.ts b/api/expenses-router.ts index 6c0dad1..1004f86 100644 --- a/api/expenses-router.ts +++ b/api/expenses-router.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { createRouter, expenseQuery, expenseCreate, expenseManage, getCurrentBusinessLocationIds, requireAuthorizedLocation, requireAuthorizedEntity, requireAuthorizedBusinessEntity } from "./middleware"; import { getDb } from "./queries/connection"; -import { expenses, expenseCategories, accounts, ledgerEntries, suppliers, bills, attachments, locations } from "@db/schema"; +import { expenses, expenseItems, expenseCategories, accounts, ledgerEntries, suppliers, bills, attachments, locations } from "@db/schema"; import { eq, and, isNull, desc, sql } from "drizzle-orm"; import { d } from "./lib/decimal"; import { notFutureDateString, optionalNotFutureDateString } from "./lib/future-date"; @@ -10,6 +10,19 @@ import { ensureSystemAccount } from "./lib/accounting-accounts"; import { getExpenseAccountSubType } from "./lib/accounting-maps"; import { reverseLedgerEntriesForTransaction } from "./lib/accounting-reversal"; +export const expenseItemInputSchema = z.object({ + itemName: z.string().min(1), + quantity: z.string().default("1"), + unitPrice: z.string(), + totalPrice: z.string(), + categoryId: z.number(), + notes: z.string().optional(), +}); + +export const expenseItemWithIdSchema = expenseItemInputSchema.extend({ + id: z.number().optional(), +}); + export const createExpenseInputSchema = z.object({ locationId: z.number(), categoryId: z.number(), @@ -38,6 +51,7 @@ export const createExpenseInputSchema = z.object({ depreciationMethod: z.enum(["straight_line", "declining_balance"]).optional(), salvageValue: z.string().optional(), assetAccountId: z.number().optional(), + items: z.array(expenseItemInputSchema).optional(), }); export const updateExpenseInputSchema = z.object({ @@ -230,6 +244,14 @@ export const expensesRouter = createRouter({ return db.select().from(expenses).where(and(...conditions)).orderBy(desc(expenses.expenseDate)).limit(input.pageSize).offset(offset); }), + getItems: expenseQuery + .input(z.object({ expenseId: z.number() })) + .query(async ({ input, ctx }) => { + const db = getDb(); + await requireAuthorizedEntity(ctx, expenses, input.expenseId); + return db.select().from(expenseItems).where(and(eq(expenseItems.expenseId, input.expenseId), isNull(expenseItems.deletedAt))); + }), + create: expenseCreate .input(createExpenseInputSchema) .mutation(async ({ input, ctx }) => { @@ -319,6 +341,20 @@ export const expensesRouter = createRouter({ } } + if (input.items && input.items.length > 0) { + for (const item of input.items) { + await tx.insert(expenseItems).values({ + expenseId, + itemName: item.itemName, + quantity: item.quantity, + unitPrice: item.unitPrice, + totalPrice: item.totalPrice, + categoryId: item.categoryId, + notes: item.notes, + } as any).returning(); + } + } + if (accountId) { const cashAcct = await tx.select().from(accounts).where(eq(accounts.id, accountId)).limit(1); if (cashAcct[0]) { diff --git a/api/reports-router.ts b/api/reports-router.ts index bbf8770..2dcc859 100644 --- a/api/reports-router.ts +++ b/api/reports-router.ts @@ -1,24 +1,37 @@ import { z } from "zod"; -import { createRouter, accountManage } from "./middleware"; +import { createRouter, reportQuery, getCurrentBusinessLocationIds } from "./middleware"; import { getDb } from "./queries/connection"; import { dailySales, expenses, bills, expenseCategories, accounts, financialReports } from "@db/schema"; import { eq, and, isNull, sql, inArray } from "drizzle-orm"; import { d } from "./lib/decimal"; +async function resolveLocationFilter(ctx: any, inputLocationId?: number): Promise { + const locIds = await getCurrentBusinessLocationIds(ctx); + if (inputLocationId !== undefined) { + if (!locIds.includes(inputLocationId)) { + throw new Error("Invalid location for the current business"); + } + return [inputLocationId]; + } + return locIds; +} + export const reportsRouter = createRouter({ - plStatement: accountManage + plStatement: reportQuery .input(z.object({ year: z.number(), month: z.number(), locationId: z.number().optional() })) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { const db = getDb(); const startDate = `${input.year}-${String(input.month).padStart(2, "0")}-01`; const endDate = new Date(input.year, input.month, 0).toISOString().split("T")[0]; + const locFilter = await resolveLocationFilter(ctx, input.locationId); + const locIdSql = sql.join(locFilter.map(id => sql`${id}`), sql`, `); const salesCond: any[] = [ sql`${dailySales.saleDate} >= ${startDate}`, sql`${dailySales.saleDate} <= ${endDate}`, isNull(dailySales.deletedAt), + sql`${dailySales.locationId} IN (${locIdSql})`, ]; - if (input.locationId) salesCond.push(eq(dailySales.locationId, input.locationId)); const salesData = await db.select().from(dailySales).where(and(...salesCond)); const revenue = salesData.reduce((sum, s) => sum.plus(d(s.netSales || "0")), d(0)); @@ -26,8 +39,8 @@ export const reportsRouter = createRouter({ sql`${expenses.expenseDate} >= ${startDate}`, sql`${expenses.expenseDate} <= ${endDate}`, isNull(expenses.deletedAt), + sql`${expenses.locationId} IN (${locIdSql})`, ]; - if (input.locationId) expenseCond.push(eq(expenses.locationId, input.locationId)); const expenseResult = await db .select({ total: sql`COALESCE(SUM(${expenses.amount}), 0)` }) .from(expenses) @@ -49,10 +62,12 @@ export const reportsRouter = createRouter({ }; }), - plMonthly: accountManage + plMonthly: reportQuery .input(z.object({ year: z.number(), locationId: z.number().optional() })) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { const db = getDb(); + const locFilter = await resolveLocationFilter(ctx, input.locationId); + const locIdSql = sql.join(locFilter.map(id => sql`${id}`), sql`, `); const months: { month: number; monthName: string; @@ -68,8 +83,8 @@ export const reportsRouter = createRouter({ sql`${dailySales.saleDate} >= ${monthStart}`, sql`${dailySales.saleDate} <= ${monthEnd}`, isNull(dailySales.deletedAt), + sql`${dailySales.locationId} IN (${locIdSql})`, ]; - if (input.locationId) salesCond.push(eq(dailySales.locationId, input.locationId)); const salesData = await db.select().from(dailySales).where(and(...salesCond)); const revenue = salesData.reduce((sum, s) => sum.plus(d(s.netSales || "0")), d(0)); @@ -77,8 +92,8 @@ export const reportsRouter = createRouter({ sql`${expenses.expenseDate} >= ${monthStart}`, sql`${expenses.expenseDate} <= ${monthEnd}`, isNull(expenses.deletedAt), + sql`${expenses.locationId} IN (${locIdSql})`, ]; - if (input.locationId) expenseCond.push(eq(expenses.locationId, input.locationId)); const expenseResult = await db .select({ total: sql`COALESCE(SUM(${expenses.amount}), 0)` }) .from(expenses) @@ -96,23 +111,23 @@ export const reportsRouter = createRouter({ return months; }), - plComparative: accountManage + plComparative: reportQuery .input(z.object({ locationId: z.number().optional() })) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { const db = getDb(); + const locFilter = await resolveLocationFilter(ctx, input.locationId); + const locIdSql = sql.join(locFilter.map(id => sql`${id}`), sql`, `); const thisYear = new Date().getFullYear(); const lastYear = thisYear - 1; const getTotals = async (year: number) => { const start = `${year}-01-01`; const end = `${year}-12-31`; - const salesCond: any[] = [sql`${dailySales.saleDate} >= ${start}`, sql`${dailySales.saleDate} <= ${end}`, isNull(dailySales.deletedAt)]; - if (input.locationId) salesCond.push(eq(dailySales.locationId, input.locationId)); + const salesCond: any[] = [sql`${dailySales.saleDate} >= ${start}`, sql`${dailySales.saleDate} <= ${end}`, isNull(dailySales.deletedAt), sql`${dailySales.locationId} IN (${locIdSql})`]; const salesData = await db.select().from(dailySales).where(and(...salesCond)); const revenue = salesData.reduce((sum, s) => sum.plus(d(s.netSales || "0")), d(0)); - const expenseCond: any[] = [sql`${expenses.expenseDate} >= ${start}`, sql`${expenses.expenseDate} <= ${end}`, isNull(expenses.deletedAt)]; - if (input.locationId) expenseCond.push(eq(expenses.locationId, input.locationId)); + const expenseCond: any[] = [sql`${expenses.expenseDate} >= ${start}`, sql`${expenses.expenseDate} <= ${end}`, isNull(expenses.deletedAt), sql`${expenses.locationId} IN (${locIdSql})`]; const expenseResult = await db.select({ total: sql`COALESCE(SUM(${expenses.amount}), 0)` }).from(expenses).where(and(...expenseCond)); const expensesTotal = d(expenseResult[0]?.total || "0"); @@ -131,10 +146,12 @@ export const reportsRouter = createRouter({ }; }), - budgetVsActual: accountManage + budgetVsActual: reportQuery .input(z.object({ year: z.number(), month: z.number().optional(), locationId: z.number().optional() })) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { const db = getDb(); + const locFilter = await resolveLocationFilter(ctx, input.locationId); + const locIdSql = sql.join(locFilter.map(id => sql`${id}`), sql`, `); const startDate = `${input.year}-01-01`; const endDate = input.month ? new Date(input.year, input.month, 0).toISOString().split("T")[0] : `${input.year}-12-31`; @@ -142,8 +159,8 @@ export const reportsRouter = createRouter({ sql`${expenses.expenseDate} >= ${startDate}`, sql`${expenses.expenseDate} <= ${endDate}`, isNull(expenses.deletedAt), + sql`${expenses.locationId} IN (${locIdSql})`, ]; - if (input.locationId) expenseCond.push(eq(expenses.locationId, input.locationId)); const expenseData = await db .select({ @@ -176,10 +193,12 @@ export const reportsRouter = createRouter({ }; }), - cashFlowForecast: accountManage + cashFlowForecast: reportQuery .input(z.object({ locationId: z.number().optional() })) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { const db = getDb(); + const locFilter = await resolveLocationFilter(ctx, input.locationId); + const locIdSql = sql.join(locFilter.map(id => sql`${id}`), sql`, `); const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]; const today = new Date().toISOString().split("T")[0]; const next30 = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]; @@ -188,8 +207,8 @@ export const reportsRouter = createRouter({ sql`${dailySales.saleDate} >= ${thirtyDaysAgo}`, sql`${dailySales.saleDate} <= ${today}`, isNull(dailySales.deletedAt), + sql`${dailySales.locationId} IN (${locIdSql})`, ]; - if (input.locationId) salesCond.push(eq(dailySales.locationId, input.locationId)); const salesData = await db.select().from(dailySales).where(and(...salesCond)); const totalSales = salesData.reduce((sum, s) => sum.plus(d(s.netSales || "0")), d(0)); const avgDaily = totalSales.gt(0) ? totalSales.dividedBy(30) : d(0); @@ -199,8 +218,8 @@ export const reportsRouter = createRouter({ sql`${bills.dueDate} <= ${next30}`, sql`${bills.balanceDue} > 0`, isNull(bills.deletedAt), + sql`${bills.locationId} IN (${locIdSql})`, ]; - if (input.locationId) billCond.push(eq(bills.locationId, input.locationId)); const billData = await db.select().from(bills).where(and(...billCond)); const totalBills = billData.reduce((sum, b) => sum.plus(d(b.balanceDue || "0")), d(0)); @@ -214,10 +233,12 @@ export const reportsRouter = createRouter({ }; }), - cogsAnalysis: accountManage + cogsAnalysis: reportQuery .input(z.object({ year: z.number(), month: z.number().optional(), locationId: z.number().optional() })) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { const db = getDb(); + const locFilter = await resolveLocationFilter(ctx, input.locationId); + const locIdSql = sql.join(locFilter.map(id => sql`${id}`), sql`, `); const startDate = input.month ? `${input.year}-${String(input.month).padStart(2, "0")}-01` : `${input.year}-01-01`; const endDate = input.month ? new Date(input.year, input.month, 0).toISOString().split("T")[0] : `${input.year}-12-31`; @@ -225,8 +246,8 @@ export const reportsRouter = createRouter({ sql`${dailySales.saleDate} >= ${startDate}`, sql`${dailySales.saleDate} <= ${endDate}`, isNull(dailySales.deletedAt), + sql`${dailySales.locationId} IN (${locIdSql})`, ]; - if (input.locationId) salesCond.push(eq(dailySales.locationId, input.locationId)); const salesData = await db.select().from(dailySales).where(and(...salesCond)); const revenue = salesData.reduce((sum, s) => sum.plus(d(s.netSales || "0")), d(0)); @@ -244,8 +265,8 @@ export const reportsRouter = createRouter({ sql`${expenses.expenseDate} <= ${endDate}`, inArray(expenses.categoryId, cogsCatIds), isNull(expenses.deletedAt), + sql`${expenses.locationId} IN (${locIdSql})`, ]; - if (input.locationId) cogsCond.push(eq(expenses.locationId, input.locationId)); const cogsResult = await db .select({ total: sql`COALESCE(SUM(${expenses.amount}), 0)` }) .from(expenses) @@ -272,43 +293,43 @@ export const reportsRouter = createRouter({ }; }), - getCogsTarget: accountManage + getCogsTarget: reportQuery .input(z.object({ locationId: z.number().optional() })) .query(async () => ({ targetFoodCostPercent: "35", alertThresholdPercent: "38", })), - setBudget: accountManage + setBudget: reportQuery .input(z.object({ year: z.number(), month: z.number(), budgetType: z.enum(["cogs", "sales"]), amount: z.string(), locationId: z.number() })) .mutation(async () => ({ success: true })), - setCogsTarget: accountManage + setCogsTarget: reportQuery .input(z.object({ locationId: z.number(), cogsTarget: z.string() })) .mutation(async () => ({ success: true })), - incomeStatement: accountManage + incomeStatement: reportQuery .input(z.object({ businessId: z.number(), startDate: z.string(), endDate: z.string(), saveReport: z.boolean().default(false) })) .mutation(async ({ input }) => { const { generateIncomeStatement } = await import("./lib/reports"); return generateIncomeStatement(input.businessId, new Date(input.startDate), new Date(input.endDate)); }), - balanceSheet: accountManage + balanceSheet: reportQuery .input(z.object({ businessId: z.number(), asOfDate: z.string(), saveReport: z.boolean().default(false) })) .mutation(async ({ input }) => { const { generateBalanceSheet } = await import("./lib/reports"); return generateBalanceSheet(input.businessId, new Date(input.asOfDate)); }), - trialBalance: accountManage + trialBalance: reportQuery .input(z.object({ businessId: z.number(), asOfDate: z.string(), saveReport: z.boolean().default(false) })) .mutation(async ({ input }) => { const { generateTrialBalance } = await import("./lib/reports"); return generateTrialBalance(input.businessId, new Date(input.asOfDate)); }), - assetRegister: accountManage + assetRegister: reportQuery .input(z.object({ businessId: z.number(), saveReport: z.boolean().default(false) })) .mutation(async ({ input }) => { const { generateAssetRegister } = await import("./lib/reports"); diff --git a/db/migrations/0002_soft_flamingo.sql b/db/migrations/0002_soft_flamingo.sql new file mode 100644 index 0000000..649108d --- /dev/null +++ b/db/migrations/0002_soft_flamingo.sql @@ -0,0 +1,29 @@ +-- Migration: Add expense_items table for expense line items with multiple categories +-- Created for: Multi-category expense support + +CREATE TABLE IF NOT EXISTS "expense_items" ( + "id" serial PRIMARY KEY, + "expenseId" bigint NOT NULL, + "itemName" varchar(255) NOT NULL, + "quantity" numeric(10, 3) DEFAULT '1.000' NOT NULL, + "unitPrice" numeric(15, 2) NOT NULL, + "totalPrice" numeric(15, 2) NOT NULL, + "categoryId" bigint NOT NULL, + "notes" text, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "deletedAt" timestamp +); + +-- Index for querying items by expense +CREATE INDEX IF NOT EXISTS "expense_items_expense_id_idx" ON "expense_items" ("expenseId"); +CREATE INDEX IF NOT EXISTS "expense_items_category_id_idx" ON "expense_items" ("categoryId"); +CREATE INDEX IF NOT EXISTS "expense_items_deleted_at_idx" ON "expense_items" ("deletedAt"); + +-- Add foreign key constraint +ALTER TABLE "expense_items" ADD CONSTRAINT "expense_items_expense_id_fkey" FOREIGN KEY ("expenseId") REFERENCES "expenses"("id") ON DELETE NO ACTION; +ALTER TABLE "expense_items" ADD CONSTRAINT "expense_items_category_id_fkey" FOREIGN KEY ("categoryId") REFERENCES "expense_categories"("id") ON DELETE NO ACTION; + +COMMENT ON TABLE "expense_items" IS 'Line items for expenses with multiple category support'; +COMMENT ON COLUMN "expense_items"."expenseId" IS 'Reference to the parent expense'; +COMMENT ON COLUMN "expense_items"."categoryId" IS 'Category for this line item (allows different categories per expense)'; diff --git a/db/migrations/meta/0002_snapshot.json b/db/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..fa10221 --- /dev/null +++ b/db/migrations/meta/0002_snapshot.json @@ -0,0 +1,54 @@ +{ + "id": "0002-soft-flamingo", + "prevId": "0001-gifted-secret-warriors", + "version": "7", + "dialect": "postgresql", + "tables": { + "expense_items": { + "name": "expense_items", + "columns": { + "id": { "name": "id", "type": "serial", "primaryKey": true, "autoincrement": true }, + "expenseId": { "name": "expenseId", "type": "bigint", "primaryKey": false, "autoincrement": false }, + "itemName": { "name": "itemName", "type": "varchar(255)", "primaryKey": false, "autoincrement": false }, + "quantity": { "name": "quantity", "type": "numeric(10, 3)", "primaryKey": false, "autoincrement": false, "default": "'1.000'" }, + "unitPrice": { "name": "unitPrice", "type": "numeric(15, 2)", "primaryKey": false, "autoincrement": false }, + "totalPrice": { "name": "totalPrice", "type": "numeric(15, 2)", "primaryKey": false, "autoincrement": false }, + "categoryId": { "name": "categoryId", "type": "bigint", "primaryKey": false, "autoincrement": false }, + "notes": { "name": "notes", "type": "text", "primaryKey": false, "autoincrement": false }, + "createdAt": { "name": "createdAt", "type": "timestamp", "primaryKey": false, "autoincrement": false, "default": "now()" }, + "updatedAt": { "name": "updatedAt", "type": "timestamp", "primaryKey": false, "autoincrement": false, "default": "now()" }, + "deletedAt": { "name": "deletedAt", "type": "timestamp", "primaryKey": false, "autoincrement": false } + }, + "indexes": { + "expense_items_expense_id_idx": { "name": "expense_items_expense_id_idx", "columns": ["expenseId"] }, + "expense_items_category_id_idx": { "name": "expense_items_category_id_idx", "columns": ["categoryId"] }, + "expense_items_deleted_at_idx": { "name": "expense_items_deleted_at_idx", "columns": ["deletedAt"] } + }, + "foreignKeys": { + "expense_items_expense_id_fkey": { + "name": "expense_items_expense_id_fkey", + "tableFrom": "expense_items", + "tableTo": "expenses", + "columnsFrom": ["expenseId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "expense_items_category_id_fkey": { + "name": "expense_items_category_id_fkey", + "tableFrom": "expense_items", + "tableTo": "expense_categories", + "columnsFrom": ["categoryId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "schemas": {}, + "sequences": {} +} diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index cb82ecc..f0129f7 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1779017871967, "tag": "0001_gifted_secret_warriors", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1779122000000, + "tag": "0002_soft_flamingo", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/db/relations.ts b/db/relations.ts index 34c420d..b04acdd 100644 --- a/db/relations.ts +++ b/db/relations.ts @@ -9,6 +9,7 @@ import { accounts, dailySales, expenses, + expenseItems, expenseCategories, suppliers, bills, @@ -102,7 +103,7 @@ export const masterItemsRelations = relations(masterItems, ({ one }) => ({ }), })); -export const expensesRelations = relations(expenses, ({ one }) => ({ +export const expensesRelations = relations(expenses, ({ one, many }) => ({ location: one(locations, { fields: [expenses.locationId], references: [locations.id], @@ -119,6 +120,18 @@ export const expensesRelations = relations(expenses, ({ one }) => ({ fields: [expenses.accountId], references: [accounts.id], }), + items: many(expenseItems), +})); + +export const expenseItemsRelations = relations(expenseItems, ({ one }) => ({ + expense: one(expenses, { + fields: [expenseItems.expenseId], + references: [expenses.id], + }), + category: one(expenseCategories, { + fields: [expenseItems.categoryId], + references: [expenseCategories.id], + }), })); export const employeesRelations = relations(employees, ({ one, many }) => ({ diff --git a/db/schema.ts b/db/schema.ts index f9669c5..1b3456f 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -328,6 +328,23 @@ export const expenses = pgTable("expenses", { export type Expense = typeof expenses.$inferSelect; +// Expense line items - for expenses with multiple categories +export const expenseItems = pgTable("expense_items", { + id: serial("id").primaryKey(), + expenseId: bigint("expenseId", { mode: "number" }).notNull(), + itemName: varchar("itemName", { length: 255 }).notNull(), + quantity: numeric("quantity", { precision: 10, scale: 3 }).default("1.000").notNull(), + unitPrice: numeric("unitPrice", { precision: 15, scale: 2 }).notNull(), + totalPrice: numeric("totalPrice", { precision: 15, scale: 2 }).notNull(), + categoryId: bigint("categoryId", { mode: "number" }).notNull(), + notes: text("notes"), + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt").defaultNow().notNull().$onUpdate(() => new Date()), + deletedAt: timestamp("deletedAt"), +}); + +export type ExpenseItem = typeof expenseItems.$inferSelect; + // Suppliers export const suppliers = pgTable("suppliers", { id: serial("id").primaryKey(), diff --git a/src/pages/Bills.tsx b/src/pages/Bills.tsx index d139b0f..c6f8231 100644 --- a/src/pages/Bills.tsx +++ b/src/pages/Bills.tsx @@ -44,6 +44,13 @@ export function Bills() { ); const utils = trpc.useUtils(); + + const selectedBillForItems = itemsOpen ? bills?.find(b => b.id === itemsOpen) : null; + const itemsTotal = billItemsData?.reduce((sum, item) => sum + parseFloat(String(item.totalPrice)), 0) ?? 0; + const billAmount = selectedBillForItems ? parseFloat(selectedBillForItems.amount) : 0; + const remainingAmount = billAmount - itemsTotal; + const isOverBudget = remainingAmount < 0; + const createBill = trpc.bills.create.useMutation({ onSuccess: () => { setOpen(false); utils.bills.list.invalidate(); } }); const createRecurring = trpc.bills.createRecurring.useMutation({ onSuccess: () => { setRecurringOpen(false); utils.bills.listRecurring.invalidate(); } }); const deleteRecurring = trpc.bills.deleteRecurring.useMutation({ onSuccess: () => { utils.bills.listRecurring.invalidate(); utils.bills.list.invalidate(); } }); @@ -160,16 +167,22 @@ export function Bills() { if (!itemsOpen) return; const qty = parseFloat(itemForm.quantity) || 1; const price = parseFloat(itemForm.unitPrice) || 0; + const itemTotal = qty * price; + + if (billAmount > 0 && itemsTotal + itemTotal > billAmount) { + toast.error(`Item total (${formatKES(itemTotal)}) would exceed bill amount (${formatKES(billAmount)}). Remaining: ${formatKES(remainingAmount)}`); + return; + } + addItem.mutate({ billId: itemsOpen, itemName: itemForm.itemName, quantity: qty.toString(), unitPrice: price.toFixed(2), - totalPrice: (qty * price).toFixed(2), + totalPrice: itemTotal.toFixed(2), categoryId: itemForm.categoryId ? +itemForm.categoryId : undefined, notes: itemForm.notes, }); - // Reset form but keep suggestions ready setItemForm({ itemName: "", quantity: "1", unitPrice: "", totalPrice: "", categoryId: "", notes: "" }); setShowSuggestions(false); }; @@ -346,6 +359,25 @@ export function Bills() {
{billItemsData?.map(item => )}{(!billItemsData || billItemsData.length === 0) && }
ItemQtyUnit PriceTotalCategory
{item.itemName}{item.quantity}{formatKES(item.unitPrice)}{formatKES(item.totalPrice)}{categories?.find(c => c.id === item.categoryId)?.name ?? "-"}
No items yet.
+ {selectedBillForItems && ( +
0 ? "bg-green-50 border border-green-200" : ""}`}> +
+

Bill Amount

+

{formatKES(billAmount)}

+
+
+

Items Total

+

{formatKES(itemsTotal)}

+
+
+

Remaining

+

= billAmount ? "text-green-600" : ""}`}> + {isOverBudget && } + {isOverBudget ? `OVER by ${formatKES(Math.abs(remainingAmount))}` : formatKES(remainingAmount)} +

+
+
+ )} {canCreate && (
diff --git a/src/pages/Expenses.tsx b/src/pages/Expenses.tsx index 6d41ada..9d5a86a 100644 --- a/src/pages/Expenses.tsx +++ b/src/pages/Expenses.tsx @@ -120,7 +120,7 @@ export function Expenses() { }); const [form, setForm] = useState({ - locationId: "", categoryId: "", supplierId: "", amount: "", description: "", + locationId: "", categoryIds: [] as number[], supplierId: "", amount: "", description: "", expenseDate: getLocalDateString(), paymentMethod: "cash" as const, accountId: "", billId: "", }); @@ -134,49 +134,61 @@ export function Expenses() { { enabled: !!selectedBillId } ); + const getCategoriesFromBillItems = (items: typeof billItems): number[] => { + if (!items || items.length === 0) return []; + const uniqueCategories = [...new Set(items.map(item => item.categoryId).filter(Boolean))]; + return uniqueCategories as number[]; + }; + const photosEnabled = settings?.photosExpenses !== "false"; const selectedSupplier = suppliers?.find(s => s.id.toString() === form.supplierId); const selectedBill = bills?.find(b => b.id.toString() === form.billId); - - const getCategoryFromBillItems = (items: typeof billItems): number | undefined => { - if (!items || items.length === 0) return undefined; - const uniqueCategories = [...new Set(items.map(item => item.categoryId).filter(Boolean))]; - return uniqueCategories.length === 1 ? uniqueCategories[0] : undefined; + const itemCategories = billItems ? getCategoriesFromBillItems(billItems) : []; + const hasMultiCategoryItems = !!form.billId && !!billItems && billItems.length > 0 && itemCategories.length > 1; + + const groupBillItemsByCategory = (items: typeof billItems): Map => { + const grouped = new Map(); + if (!items) return grouped; + for (const item of items) { + const catId = item.categoryId; + if (!grouped.has(catId)) { + grouped.set(catId, []); + } + grouped.get(catId)!.push(item); + } + return grouped; }; useEffect(() => { if (!form.billId || !selectedBill) return; - let categoryId: string | undefined; + let categoryIds: number[] = []; if (selectedBill.categoryId) { - categoryId = String(selectedBill.categoryId); + categoryIds = [selectedBill.categoryId]; } else if (billItems && billItems.length > 0) { - const itemCategory = getCategoryFromBillItems(billItems); - if (itemCategory) { - categoryId = String(itemCategory); - } + categoryIds = getCategoriesFromBillItems(billItems); } - if (!categoryId && selectedSupplier?.autoCategoryId) { - categoryId = String(selectedSupplier.autoCategoryId); + if (categoryIds.length === 0 && selectedSupplier?.autoCategoryId) { + categoryIds = [selectedSupplier.autoCategoryId]; } setForm(p => ({ ...p, supplierId: selectedBill.supplierId ? String(selectedBill.supplierId) : p.supplierId, - categoryId: categoryId ?? p.categoryId, + categoryIds: categoryIds.length > 0 ? categoryIds : p.categoryIds, })); }, [form.billId, selectedBill, billItems, selectedSupplier?.autoCategoryId]); useEffect(() => { - if (!form.billId && !form.categoryId && selectedSupplier?.autoCategoryId) { + if (!form.billId && form.categoryIds.length === 0 && selectedSupplier?.autoCategoryId) { setForm((previous) => ({ ...previous, - categoryId: String(selectedSupplier.autoCategoryId), + categoryIds: [selectedSupplier.autoCategoryId], })); } - }, [form.billId, form.categoryId, selectedSupplier?.autoCategoryId]); + }, [form.billId, form.categoryIds, selectedSupplier?.autoCategoryId]); const handleFile = async (e: React.ChangeEvent) => { const files = e.target.files; @@ -191,26 +203,96 @@ export function Expenses() { }; const resetForm = () => { - setForm({ locationId: "", categoryId: "", supplierId: "", amount: "", description: "", expenseDate: getLocalDateString(), paymentMethod: "cash", accountId: "", billId: "" }); + setForm({ locationId: "", categoryIds: [], supplierId: "", amount: "", description: "", expenseDate: getLocalDateString(), paymentMethod: "cash", accountId: "", billId: "" }); setAttachments([]); }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!form.locationId) { toast.error("Please select a location"); return; } - if (!form.categoryId) { - if (form.billId) { - toast.error("The selected bill has no category. Please edit the bill to add a category, or select a category manually."); + if (!form.amount || parseFloat(form.amount) <= 0) { toast.error("Please enter a valid amount"); return; } + if (!form.description.trim()) { toast.error("Please enter a description"); return; } + if (form.expenseDate > todayDate) { toast.error("Expense date cannot be in the future"); return; } + + const hasBillWithItems = form.billId && billItems && billItems.length > 0; + const itemCategories = hasBillWithItems ? getCategoriesFromBillItems(billItems) : []; + const hasMultiCategoryItems = hasBillWithItems && itemCategories.length > 1; + const billHasOwnCategory = !!selectedBill?.categoryId; + + if (form.categoryIds.length === 0) { + if (hasBillWithItems) { + // Bill has items - allow proceeding (will create expense with items) + } else if (form.billId) { + toast.error("The selected bill has no items. Please add items to the bill or select a category manually."); + return; } else { - toast.error("Please select a category"); + toast.error("Please select at least one category"); + return; } + } + + if (hasMultiCategoryItems && !billHasOwnCategory) { + const grouped = groupBillItemsByCategory(billItems); + const expenseItems = Array.from(grouped.entries()).flatMap(([categoryId, items]) => { + return items.map(item => ({ + itemName: item.itemName, + quantity: item.quantity, + unitPrice: item.unitPrice, + totalPrice: item.totalPrice, + categoryId: categoryId ? Number(categoryId) : form.categoryIds[0], + notes: undefined as string | undefined, + })); + }); + + const totalAmount = expenseItems.reduce((sum, item) => sum + parseFloat(String(item.totalPrice)), 0); + + toast.info("Creating expense with line items..."); + + createExpense.mutate({ + locationId: +form.locationId, + categoryId: form.categoryIds[0], + supplierId: form.supplierId ? +form.supplierId : undefined, + amount: totalAmount.toFixed(2), + description: form.description, + expenseDate: form.expenseDate, + paymentMethod: form.paymentMethod, + accountId: form.accountId ? +form.accountId : undefined, + billId: form.billId ? +form.billId : undefined, + attachments: photosEnabled ? attachments : undefined, + items: expenseItems, + }); return; } - if (!form.amount || parseFloat(form.amount) <= 0) { toast.error("Please enter a valid amount"); return; } - if (!form.description.trim()) { toast.error("Please enter a description"); return; } - if (form.expenseDate > todayDate) { toast.error("Expense date cannot be in the future"); return; } + + if (hasBillWithItems && billItems && billItems.length > 0) { + const expenseItems = billItems.map(item => ({ + itemName: item.itemName, + quantity: item.quantity, + unitPrice: item.unitPrice, + totalPrice: item.totalPrice, + categoryId: item.categoryId ? Number(item.categoryId) : form.categoryIds[0], + notes: undefined as string | undefined, + })); + + createExpense.mutate({ + locationId: +form.locationId, + categoryId: form.categoryIds[0], + supplierId: form.supplierId ? +form.supplierId : undefined, + amount: form.amount, + description: form.description, + expenseDate: form.expenseDate, + paymentMethod: form.paymentMethod, + accountId: form.accountId ? +form.accountId : undefined, + billId: form.billId ? +form.billId : undefined, + attachments: photosEnabled ? attachments : undefined, + items: expenseItems, + }); + return; + } + + const primaryCategoryId = form.categoryIds[0] ?? 0; createExpense.mutate({ - locationId: +form.locationId, categoryId: +form.categoryId, supplierId: form.supplierId ? +form.supplierId : undefined, + locationId: +form.locationId, categoryId: primaryCategoryId, supplierId: form.supplierId ? +form.supplierId : undefined, amount: form.amount, description: form.description, expenseDate: form.expenseDate, paymentMethod: form.paymentMethod, accountId: form.accountId ? +form.accountId : undefined, billId: form.billId ? +form.billId : undefined, attachments: photosEnabled ? attachments : undefined, @@ -296,16 +378,65 @@ export function Expenses() { {locations?.map(l => )}
-
- - {form.billId && selectedBill?.categoryId &&

Category is determined by the linked bill.

} - {form.billId && !selectedBill?.categoryId && selectedSupplier?.autoCategoryId &&

Using the supplier default category until you override it.

} - {form.billId && !selectedBill?.categoryId && !selectedSupplier?.autoCategoryId &&

This bill has no saved category yet, so please choose one.

} -
+ {!hasMultiCategoryItems && ( +
+ + + {form.billId && selectedBill?.categoryId &&

Category from linked bill.

} + {form.billId && !selectedBill?.categoryId && selectedSupplier?.autoCategoryId &&

Using supplier default.

} +
+ )} + + {hasMultiCategoryItems && billItems && billItems.length > 0 && ( +
+
+

Bill Items — select categories for expenses:

+ {form.categoryIds.length} of {itemCategories.length} selected +
+
+ {billItems.map(item => { + const cat = categories?.find(c => c.id === item.categoryId); + const isSelected = item.categoryId && form.categoryIds.includes(item.categoryId); + return ( + + ); + })} +
+

Click categories to toggle. Each selected category creates a separate expense.

+
+ )} + + {hasMultiCategoryItems && form.categoryIds.length === 0 && ( +

Please select at least one category from the bill items above.

+ )}
KES