diff --git a/db.json b/db.json new file mode 100644 index 0000000..7e8deed --- /dev/null +++ b/db.json @@ -0,0 +1,581 @@ +{ + "tenants": [ + { + "id": "1", + "name": "Josette Reyna", + "unit": "13624 James Buchanan St., Manor, TX 78653", + "email": "josette0122@icloud.com", + "phone": "5129396566" + } + ], + "leases": [ + { + "id": "1", + "tenantId": 1, + "unit": "13624 James Buchanan St., Manor, TX 78653", + "rent": 1800, + "start": "2026-06-01", + "end": "2027-05-31", + "deposit": 1000, + "status": "active", + "propertyId": "1" + } + ], + "transactions": [ + { + "date": "2026-05-15", + "type": "income", + "category": "Rent", + "description": "Cash payment toward April rent", + "amount": 900, + "tenantId": 1, + "id": "GUYqYJXGHAI", + "propertyId": "1" + }, + { + "date": "2026-05-26", + "type": "income", + "category": "Rent", + "description": "Cash payment toward April & May rent", + "amount": 1050, + "tenantId": 1, + "id": "gddMA3mpRjY", + "propertyId": "1" + }, + { + "date": "2020-10-25", + "type": "expense", + "category": "Travel", + "description": "Travel — Inspection (exterior) (61 miles, $6.16 tolls)", + "amount": 6.16, + "tenantId": null, + "propertyId": "1", + "id": "Y1pghbDPtR0", + "miles": 61 + }, + { + "date": "2020-10-26", + "type": "expense", + "category": "Travel", + "description": "Travel — Inspection (interior) (61 miles, $6.16 tolls)", + "amount": 6.16, + "tenantId": null, + "propertyId": "1", + "id": "ZzBs7DrCEkM", + "miles": 61 + }, + { + "date": "2020-10-28", + "type": "expense", + "category": "Travel", + "description": "Travel — Final Inspection (60 miles, $2.0 tolls)", + "amount": 2, + "tenantId": null, + "propertyId": "1", + "id": "4EKqUO5e_jk", + "miles": 60 + }, + { + "date": "2020-11-09", + "type": "expense", + "category": "Travel", + "description": "Travel — First check after closing (60 miles, $6.0 tolls)", + "amount": 6, + "tenantId": null, + "propertyId": "1", + "id": "TU_beweNTuc", + "miles": 60 + }, + { + "date": "2020-11-09", + "type": "expense", + "category": "Travel", + "description": "Travel — Pick up & deliver refrigerator to garage (80 miles, $6.0 tolls)", + "amount": 6, + "tenantId": null, + "propertyId": "1", + "id": "2XhZ3BNV-fU", + "miles": 80 + }, + { + "date": "2020-11-14", + "type": "expense", + "category": "Travel", + "description": "Travel — Install refrigerator (60 miles, $3.79 tolls)", + "amount": 3.79, + "tenantId": null, + "propertyId": "1", + "id": "XwsTMhxagv0", + "miles": 60 + }, + { + "date": "2020-11-27", + "type": "expense", + "category": "Travel", + "description": "Travel — Install door keypad (60 miles, $6.0 tolls)", + "amount": 6, + "tenantId": null, + "propertyId": "1", + "id": "GpX2lhm4BDE", + "miles": 60 + }, + { + "date": "2020-11-30", + "type": "expense", + "category": "Travel", + "description": "Travel — Meet prospective tenant (60 miles, $6.0 tolls)", + "amount": 6, + "tenantId": null, + "propertyId": "1", + "id": "DIRAMEpmiE8", + "miles": 60 + }, + { + "date": "2020-12-13", + "type": "expense", + "category": "Travel", + "description": "Travel — Meet tenant — hand over keys (60 miles, $6.0 tolls)", + "amount": 6, + "tenantId": null, + "propertyId": "1", + "id": "BmwEa-BalhE", + "miles": 60 + }, + { + "date": "2021-04-16", + "type": "expense", + "category": "Travel", + "description": "Travel — CTMRA Toll Reconciliation (60 miles, $31.46 tolls)", + "amount": 31.46, + "tenantId": null, + "propertyId": "1", + "id": "ts2Ke0jvXoM", + "miles": 60 + }, + { + "date": "2021-08-26", + "type": "expense", + "category": "Travel", + "description": "Travel — Inspect AC failure (60 miles, $6.0 tolls)", + "amount": 6, + "tenantId": null, + "propertyId": "1", + "id": "tXQWaslzAZs", + "miles": 60 + }, + { + "date": "2022-07-01", + "type": "expense", + "category": "Travel", + "description": "Travel — Inspect foundation issues (60 miles, $6.0 tolls)", + "amount": 6, + "tenantId": null, + "propertyId": "1", + "id": "a3oBxQdUkYI", + "miles": 60 + }, + { + "date": "2022-08-02", + "type": "expense", + "category": "Travel", + "description": "Travel — Deliver roof repair supplies (60 miles, $6.0 tolls)", + "amount": 6, + "tenantId": null, + "propertyId": "1", + "id": "doy5idRp7zg", + "miles": 60 + }, + { + "date": "2022-08-27", + "type": "expense", + "category": "Travel", + "description": "Travel — Manual roof repair (60 miles, $6.0 tolls)", + "amount": 6, + "tenantId": null, + "propertyId": "1", + "id": "aMQfYjnYZcM", + "miles": 60 + }, + { + "date": "2023-05-07", + "type": "expense", + "category": "Travel", + "description": "Travel — Dishwasher inspection (60 miles, $6.0 tolls)", + "amount": 6, + "tenantId": null, + "propertyId": "1", + "id": "dAhgLxI7664", + "miles": 60 + }, + { + "date": "2023-05-07", + "type": "expense", + "category": "Travel", + "description": "Travel — Getting supplies (120 miles)", + "amount": 0, + "tenantId": null, + "propertyId": "1", + "id": "YUxV49njJ-s", + "miles": 120 + }, + { + "date": "2023-07-01", + "type": "expense", + "category": "Travel", + "description": "Travel — House inspection (60 miles, $6.0 tolls)", + "amount": 6, + "tenantId": null, + "propertyId": "1", + "id": "gstAxkQOT7o", + "miles": 60 + }, + { + "date": "2024-04-10", + "type": "expense", + "category": "Repairs", + "description": "Roof Repair", + "amount": 350, + "tenantId": null, + "propertyId": "1", + "id": "7Sx18egb2Z4" + }, + { + "date": "2024-12-20", + "type": "expense", + "category": "Labor", + "description": "Flooring Installation Labor (Josette Reyna)", + "amount": 900, + "tenantId": null, + "propertyId": "1", + "id": "X6Wx-yffrGw" + }, + { + "date": "2024-11-18", + "type": "expense", + "category": "Professional", + "description": "Property Tax Negotiation — FiveStone Tax Services", + "amount": 384, + "tenantId": null, + "propertyId": "1", + "id": "M9l8oqjyot4" + }, + { + "date": "2023-05-13", + "type": "expense", + "category": "Maintenance", + "description": "Work Truck Repair — 4 Points Automotive", + "amount": 390, + "tenantId": null, + "propertyId": "1", + "id": "S_KfIKynb7E" + }, + { + "date": "2023-05-09", + "type": "expense", + "category": "Maintenance", + "description": "Work Truck New Starter — RockAuto", + "amount": 65, + "tenantId": null, + "propertyId": "1", + "id": "3bJYQ9967gs" + }, + { + "date": "2023-03-29", + "type": "expense", + "category": "Repairs", + "description": "Roof Repair (Josette Reyna)", + "amount": 350, + "tenantId": null, + "propertyId": "1", + "id": "zrHgRVAgUM0" + }, + { + "date": "2023-01-18", + "type": "expense", + "category": "Maintenance", + "description": "Work Truck Repair — Don Rucker Automotive", + "amount": 372, + "tenantId": null, + "propertyId": "1", + "id": "T1B02QEXqRc" + }, + { + "date": "2022-11-21", + "type": "expense", + "category": "Professional", + "description": "Property Tax Negotiation — FiveStone Tax Services", + "amount": 60.42, + "tenantId": null, + "propertyId": "1", + "id": "XKDX6MKhZNc" + }, + { + "date": "2022-09-12", + "type": "expense", + "category": "Repairs", + "description": "Garage Door Spring Replacement — Door Dudes LLC", + "amount": 600, + "tenantId": null, + "propertyId": "1", + "id": "0wJvALiQiOQ" + }, + { + "date": "2022-09-03", + "type": "expense", + "category": "Repairs", + "description": "Roof Repair (Josette Reyna)", + "amount": 125, + "tenantId": null, + "propertyId": "1", + "id": "7fBeboS8k6A" + }, + { + "date": "2021-09-05", + "type": "expense", + "category": "Maintenance", + "description": "AC Maintenance — Clogged Ducting (LOMECH)", + "amount": 120, + "tenantId": null, + "propertyId": "1", + "id": "Cp_aj2IBbf8" + }, + { + "date": "2021-08-26", + "type": "expense", + "category": "Repairs", + "description": "AC Repair — Failed Capacitor (LOMECH)", + "amount": 145, + "tenantId": null, + "propertyId": "1", + "id": "Fnc7UvZdnmU" + }, + { + "date": "2020-11-12", + "type": "expense", + "category": "Utilities", + "description": "Electric — BlueBonnet Setup New Service", + "amount": 50, + "tenantId": null, + "propertyId": "1", + "id": "7asFJ8Z-cSc" + }, + { + "date": "2020-11-13", + "type": "expense", + "category": "Utilities", + "description": "Google VOIP — Setup New Line", + "amount": 10, + "tenantId": null, + "propertyId": "1", + "id": "XK6c4eeAAXc" + }, + { + "date": "2020-12-15", + "type": "expense", + "category": "Utilities", + "description": "Electric — BlueBonnet Bill", + "amount": 32.68, + "tenantId": null, + "propertyId": "1", + "id": "t8HF113i4co" + }, + { + "date": "2020-11-10", + "type": "expense", + "category": "Appliances", + "description": "Refrigerator — Craigslist", + "amount": 400, + "tenantId": null, + "propertyId": "1", + "id": "wBjPdojxWDg" + }, + { + "date": "2020-11-18", + "type": "expense", + "category": "Maintenance", + "description": "Exterior Touchup Paint — Home Depot", + "amount": 10.82, + "tenantId": null, + "propertyId": "1", + "id": "QUQyBdfcKGg" + }, + { + "date": "2020-11-23", + "type": "expense", + "category": "Maintenance", + "description": "Front Door Lock — Amazon", + "amount": 42, + "tenantId": null, + "propertyId": "1", + "id": "PeRvl7oIbX8" + }, + { + "date": "2020-11-30", + "type": "expense", + "category": "Maintenance", + "description": "Replacement Keys — KeyMe Locksmith", + "amount": 14.59, + "tenantId": null, + "propertyId": "1", + "id": "HPrWzSPlwmE" + }, + { + "date": "2022-07-05", + "type": "expense", + "category": "Maintenance", + "description": "Foundation Watering System — Amazon", + "amount": 25, + "tenantId": null, + "propertyId": "1", + "id": "V_BnNkoPAzc" + }, + { + "date": "2022-08-02", + "type": "expense", + "category": "Repairs", + "description": "Shingles/Roof Repair Supplies — Home Depot", + "amount": 50, + "tenantId": null, + "propertyId": "1", + "id": "ItcO9HTHQw8" + }, + { + "date": "2023-05-13", + "type": "expense", + "category": "Appliances", + "description": "New Dishwasher — Costco", + "amount": 627.84, + "tenantId": null, + "propertyId": "1", + "id": "7OnqBU4dryw" + }, + { + "date": "2024-12-25", + "type": "expense", + "category": "Improvements", + "description": "Light Fixtures — Amazon", + "amount": 78, + "tenantId": null, + "propertyId": "1", + "id": "d6V8oMb2-oI" + }, + { + "date": "2024-11-25", + "type": "expense", + "category": "Improvements", + "description": "Flooring Accessories (floorboards & transitions) — CarpetStop", + "amount": 125.82, + "tenantId": null, + "propertyId": "1", + "id": "2rTjpMU_3rg" + }, + { + "date": "2024-11-12", + "type": "expense", + "category": "Improvements", + "description": "Vinyl Flooring Materials (1500 sq/ft re-floor) — CarpetStop", + "amount": 3211.62, + "tenantId": null, + "propertyId": "1", + "id": "PEmxsBpK0AA" + }, + { + "date": "2020-11-20", + "type": "expense", + "category": "Mortgage", + "description": "Refinance Survey — Pendo Management", + "amount": 515, + "tenantId": null, + "propertyId": "1", + "id": "qooMK_1CT60" + }, + { + "date": "2021-01-18", + "type": "expense", + "category": "Mortgage", + "description": "LegalZoom Membership — Mortgage Docs", + "amount": 39.95, + "tenantId": null, + "propertyId": "1", + "id": "hc_v8LQ_FA8" + } + ], + "invoices": [ + { + "num": "INV-004", + "tenantId": 1, + "date": "2026-04-01", + "due": "2026-04-05", + "status": "paid", + "items": [ + { + "desc": "Monthly Rent — April 2026", + "qty": 1, + "rate": 1900 + } + ], + "id": "eVZIDUjWSGA", + "propertyId": "1" + }, + { + "num": "INV-005", + "tenantId": 1, + "date": "2026-05-01", + "due": "2026-05-05", + "status": "pending", + "items": [ + { + "desc": "Monthly Rent — May 2026", + "qty": 1, + "rate": 1900 + } + ], + "id": "8-g8T4eRvw0", + "propertyId": "1" + }, + { + "num": "INV-006", + "tenantId": 1, + "date": "2026-06-01", + "due": "2026-06-05", + "status": "pending", + "items": [ + { + "desc": "Monthly Rent — June 2026 (reduced per lease amendment)", + "qty": 1, + "rate": 1800 + } + ], + "id": "0_Fw3IlntZo", + "propertyId": "1" + }, + { + "num": "INV-007", + "tenantId": 1, + "date": "2026-07-01", + "due": "2026-07-05", + "status": "pending", + "items": [ + { + "desc": "Monthly Rent — July 2026", + "qty": 1, + "rate": 1800 + } + ], + "id": "_TRCobaUt6s", + "propertyId": "1" + } + ], + "properties": [ + { + "id": "1", + "address": "13624 James Buchanan St.", + "city": "Manor", + "state": "TX", + "zip": "78653", + "type": "Single Family Rental", + "purchaseYear": 2020 + } + ], + "$schema": "./node_modules/json-server/schema.json" +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f249fcd..5b6627d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^13.5.0", + "concurrently": "^10.0.3", + "json-server": "^1.0.0-beta.15", "react": "^19.2.7", "react-dom": "^19.2.7", "react-scripts": "5.0.1", @@ -3012,6 +3014,12 @@ } } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "license": "MIT" + }, "node_modules/@rollup/plugin-babel": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", @@ -3456,6 +3464,304 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@tinyhttp/accepts": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@tinyhttp/accepts/-/accepts-2.3.0.tgz", + "integrity": "sha512-hdKkMGAUqnagpWO1R8rVBYqbu4sWQ2Fo682gkJmO0nl54DPvnzxx81b2WZtV3VwB7EdLfUoasj2BAkyTcyZ5aw==", + "license": "MIT", + "dependencies": { + "mime": "4.1.0" + }, + "engines": { + "node": ">=14.13.1" + }, + "funding": { + "type": "individual", + "url": "https://github.com/tinyhttp/tinyhttp?sponsor=1" + } + }, + "node_modules/@tinyhttp/accepts/node_modules/mime": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", + "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", + "funding": [ + "https://github.com/sponsors/broofa" + ], + "license": "MIT", + "bin": { + "mime": "bin/cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@tinyhttp/app": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@tinyhttp/app/-/app-3.0.7.tgz", + "integrity": "sha512-btit/gSWisksJ19crNLct1mwZvX+/AYwh/H1x8SB/VGDmNFkSsdRDZaa54wW1Eq3bM7mCIr7+l7Oh3tAi/C+Bw==", + "license": "MIT", + "dependencies": { + "@tinyhttp/accepts": "^2.3.0", + "@tinyhttp/cookie": "2.1.1", + "@tinyhttp/proxy-addr": "3.0.1", + "@tinyhttp/req": "2.2.8", + "@tinyhttp/res": "2.2.11", + "@tinyhttp/router": "2.2.5", + "regexparam": "^2.0.2" + }, + "engines": { + "node": ">=16.10.0" + }, + "funding": { + "type": "individual", + "url": "https://github.com/tinyhttp/tinyhttp?sponsor=1" + } + }, + "node_modules/@tinyhttp/content-disposition": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@tinyhttp/content-disposition/-/content-disposition-2.2.4.tgz", + "integrity": "sha512-5Kc5CM2Ysn3vTTArBs2vESUt0AQiWZA86yc1TI3B+lxXmtEq133C1nxXNOgnzhrivdPZIh3zLj5gDnZjoLL5GA==", + "license": "MIT", + "engines": { + "node": ">=12.17.0" + }, + "funding": { + "type": "individual", + "url": "https://github.com/tinyhttp/tinyhttp?sponsor=1" + } + }, + "node_modules/@tinyhttp/content-type": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@tinyhttp/content-type/-/content-type-0.1.4.tgz", + "integrity": "sha512-dl6f3SHIJPYbhsW1oXdrqOmLSQF/Ctlv3JnNfXAE22kIP7FosqJHxkz/qj2gv465prG8ODKH5KEyhBkvwrueKQ==", + "license": "MIT", + "engines": { + "node": ">=12.4" + } + }, + "node_modules/@tinyhttp/cookie": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@tinyhttp/cookie/-/cookie-2.1.1.tgz", + "integrity": "sha512-h/kL9jY0e0Dvad+/QU3efKZww0aTvZJslaHj3JTPmIPC9Oan9+kYqmh3M6L5JUQRuTJYFK2nzgL2iJtH2S+6dA==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "funding": { + "type": "individual", + "url": "https://github.com/tinyhttp/tinyhttp?sponsor=1" + } + }, + "node_modules/@tinyhttp/cookie-signature": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@tinyhttp/cookie-signature/-/cookie-signature-2.1.1.tgz", + "integrity": "sha512-VDsSMY5OJfQJIAtUgeQYhqMPSZptehFSfvEEtxr+4nldPA8IImlp3QVcOVuK985g4AFR4Hl1sCbWCXoqBnVWnw==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@tinyhttp/cors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tinyhttp/cors/-/cors-2.0.1.tgz", + "integrity": "sha512-qrmo6WJuaiCzKWagv2yA/kw6hIISfF/hOqPWwmI6w0o8apeTMmRN3DoCFvQ/wNVuWVdU5J4KU7OX8aaSOEq51A==", + "license": "MIT", + "dependencies": { + "@tinyhttp/vary": "^0.1.3" + }, + "engines": { + "node": ">=12.20 || 14.x || >=16" + } + }, + "node_modules/@tinyhttp/encode-url": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@tinyhttp/encode-url/-/encode-url-2.1.1.tgz", + "integrity": "sha512-AhY+JqdZ56qV77tzrBm0qThXORbsVjs/IOPgGCS7x/wWnsa/Bx30zDUU/jPAUcSzNOzt860x9fhdGpzdqbUeUw==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@tinyhttp/etag": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@tinyhttp/etag/-/etag-2.1.2.tgz", + "integrity": "sha512-j80fPKimGqdmMh6962y+BtQsnYPVCzZfJw0HXjyH70VaJBHLKGF+iYhcKqzI3yef6QBNa8DKIPsbEYpuwApXTw==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@tinyhttp/forwarded": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@tinyhttp/forwarded/-/forwarded-2.1.2.tgz", + "integrity": "sha512-9H/eulJ68ElY/+zYpTpNhZ7vxGV+cnwaR6+oQSm7bVgZMyuQfgROW/qvZuhmgDTIxnGMXst+Ba4ij6w6Krcs3w==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@tinyhttp/logger": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@tinyhttp/logger/-/logger-2.1.0.tgz", + "integrity": "sha512-Ma1fJ9CwUbn9r61/4HW6+nflsVoslpOnCrfQ6UeZq7GGIgwLzofms3HoSVG7M+AyRMJpxlfcDdbH5oFVroDMKA==", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.20", + "dayjs": "^1.11.13", + "http-status-emojis": "^2.2.0" + }, + "engines": { + "node": ">=14.18 || >=16.20" + } + }, + "node_modules/@tinyhttp/proxy-addr": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@tinyhttp/proxy-addr/-/proxy-addr-3.0.1.tgz", + "integrity": "sha512-vP0JVsy9ZMIldsaP/QHdMF+sb3B6wn7e2QXRdqpX/Cqz1ie35Am29DK88DeVmiwdTQle3FtYaVNtU3RgTGYZ+w==", + "license": "MIT", + "dependencies": { + "@tinyhttp/forwarded": "2.1.2" + }, + "engines": { + "node": ">=16.10.0" + } + }, + "node_modules/@tinyhttp/req": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@tinyhttp/req/-/req-2.2.8.tgz", + "integrity": "sha512-HCsceFNgMpssUsnRao16iJyyfdWRwKlhL7OMTPUEjZsZGREnBzpjlrPHA31G5xNNzR7XOWVXDXGyrGgSpcwGSA==", + "license": "MIT", + "dependencies": { + "@tinyhttp/accepts": "2.3.0", + "@tinyhttp/type-is": "2.2.5", + "@tinyhttp/url": "2.1.1", + "header-range-parser": "^1.1.3" + }, + "engines": { + "node": ">=14.13.1" + } + }, + "node_modules/@tinyhttp/res": { + "version": "2.2.11", + "resolved": "https://registry.npmjs.org/@tinyhttp/res/-/res-2.2.11.tgz", + "integrity": "sha512-t7GJzjqpG2svJ11RYvqaYU+xTV9MsEr0usbTIAWa5B8d7IIzpMC6AlT2K6wCIu1XGUYRpZY7qMM5cMgluvsfeg==", + "license": "MIT", + "dependencies": { + "@tinyhttp/content-disposition": "2.2.4", + "@tinyhttp/cookie": "2.1.1", + "@tinyhttp/cookie-signature": "2.1.1", + "@tinyhttp/encode-url": "2.1.1", + "@tinyhttp/req": "2.2.8", + "@tinyhttp/send": "2.2.5", + "@tinyhttp/vary": "^0.1.3", + "mime": "4.1.0" + }, + "engines": { + "node": ">=14.13.1" + } + }, + "node_modules/@tinyhttp/res/node_modules/mime": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", + "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", + "funding": [ + "https://github.com/sponsors/broofa" + ], + "license": "MIT", + "bin": { + "mime": "bin/cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@tinyhttp/router": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@tinyhttp/router/-/router-2.2.5.tgz", + "integrity": "sha512-HI9Mpo9+IVpCzx/36okjJvtvifBSh3Ufhl9n1vylAbNLEykceJiMBkr06+W0qqRlo8TiZeUtg2XinEJu+GFcRA==", + "license": "MIT", + "dependencies": { + "regexparam": "^2.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tinyhttp/send": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@tinyhttp/send/-/send-2.2.5.tgz", + "integrity": "sha512-XhBwziPOCydOJzb9rVw0xuKX6HmMA0gXKHHqec7V97jU6JtSCCIvwW2FBMh/XrG9S5W8DRETroPohASrIwf7Uw==", + "license": "MIT", + "dependencies": { + "@tinyhttp/content-type": "^0.1.4", + "@tinyhttp/etag": "2.1.2", + "mime": "4.1.0" + }, + "engines": { + "node": ">=14.13.1" + } + }, + "node_modules/@tinyhttp/send/node_modules/mime": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", + "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", + "funding": [ + "https://github.com/sponsors/broofa" + ], + "license": "MIT", + "bin": { + "mime": "bin/cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@tinyhttp/type-is": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@tinyhttp/type-is/-/type-is-2.2.5.tgz", + "integrity": "sha512-BCPEB+NV8v/9lzEE9GbfRPAKVsyayp84m6SSWn70j8yFkPBXeuVeq004pwVrjW1CRdmAZz9ZSH147pqqzAdr5g==", + "license": "MIT", + "dependencies": { + "@tinyhttp/content-type": "^0.1.4", + "mime": "4.1.0" + }, + "engines": { + "node": ">=14.13.1" + } + }, + "node_modules/@tinyhttp/type-is/node_modules/mime": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", + "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", + "funding": [ + "https://github.com/sponsors/broofa" + ], + "license": "MIT", + "bin": { + "mime": "bin/cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@tinyhttp/url": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@tinyhttp/url/-/url-2.1.1.tgz", + "integrity": "sha512-POJeq2GQ5jI7Zrdmj22JqOijB5/GeX+LEX7DUdml1hUnGbJOTWDx7zf2b5cCERj7RoXL67zTgyzVblBJC+NJWg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@tinyhttp/vary": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@tinyhttp/vary/-/vary-0.1.3.tgz", + "integrity": "sha512-SoL83sQXAGiHN1jm2VwLUWQSQeDAAl1ywOm6T0b0Cg1CZhVsjoiZadmjhxF6FHCCY7OHHVaLnTgSMxTPIDLxMg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -5789,6 +6095,173 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "license": "MIT" }, + "node_modules/concurrently": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz", + "integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==", + "license": "MIT", + "dependencies": { + "chalk": "5.6.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.4", + "supports-color": "10.2.2", + "tree-kill": "1.2.2", + "yargs": "18.0.0" + }, + "bin": { + "conc": "dist/bin/index.js", + "concurrently": "dist/bin/index.js" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/concurrently/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/concurrently/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/concurrently/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/concurrently/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/confusing-browser-globals": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz", @@ -6372,6 +6845,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -6714,6 +7193,36 @@ "tslib": "^2.0.3" } }, + "node_modules/dot-prop": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-10.1.0.tgz", + "integrity": "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==", + "license": "MIT", + "dependencies": { + "type-fest": "^5.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dot-prop/node_modules/type-fest": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", + "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dotenv": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", @@ -7750,6 +8259,18 @@ "node": ">=0.10.0" } }, + "node_modules/eta": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/eta/-/eta-4.6.0.tgz", + "integrity": "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/bgub/eta?sponsor=1" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -8444,6 +8965,18 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -8800,6 +9333,15 @@ "he": "bin/he" } }, + "node_modules/header-range-parser": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/header-range-parser/-/header-range-parser-1.1.5.tgz", + "integrity": "sha512-n5JOx67HBL0MGqtu6NFoEYWb+xDYAOgBI5dBkyMDff1xHbhGnjCMglj1aiMNPHps6HwXO+2i5jbPU/zJSk7etQ==", + "license": "MIT", + "engines": { + "node": ">=12.22.0" + } + }, "node_modules/hoopy": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/hoopy/-/hoopy-0.1.4.tgz", @@ -9047,6 +9589,12 @@ } } }, + "node_modules/http-status-emojis": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/http-status-emojis/-/http-status-emojis-2.2.0.tgz", + "integrity": "sha512-ompKtgwpx8ff0hsbpIB7oE4ax1LXoHmftsHHStMELX56ivG3GhofTX8ZHWlUaFKfGjcGjw6G3rPk7dJRXMmbbg==", + "license": "MIT" + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -9192,6 +9740,15 @@ "node": ">=8" } }, + "node_modules/inflection": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-3.0.2.tgz", + "integrity": "sha512-+Bg3+kg+J6JUWn8J6bzFmOWkTQ6L/NHfDRSYU+EVvuKHDxUDHAXgqixHfVlzuBQaPOTac8hn43aPhMNk6rMe3g==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -10907,6 +11464,73 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "license": "MIT" }, + "node_modules/json-server": { + "version": "1.0.0-beta.15", + "resolved": "https://registry.npmjs.org/json-server/-/json-server-1.0.0-beta.15.tgz", + "integrity": "sha512-I5UB/OWHLGoQW9IVld2yzZFYYiGYNBn2OYRlGbkfj5xDidT26yKYkV7Sr093zJtPh9zbofaLgyT89Ov5U0RRBQ==", + "license": "MIT", + "dependencies": { + "@tinyhttp/app": "^3.0.1", + "@tinyhttp/cors": "^2.0.1", + "@tinyhttp/logger": "^2.1.0", + "chalk": "^5.6.2", + "chokidar": "^5.0.0", + "dot-prop": "^10.1.0", + "eta": "^4.5.0", + "inflection": "^3.0.2", + "json5": "^2.2.3", + "lowdb": "^7.0.1", + "milliparsec": "^5.1.0", + "sirv": "^3.0.2", + "sort-on": "^7.0.0" + }, + "bin": { + "json-server": "lib/bin.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/json-server/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/json-server/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/json-server/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -11172,6 +11796,21 @@ "loose-envify": "cli.js" } }, + "node_modules/lowdb": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/lowdb/-/lowdb-7.0.1.tgz", + "integrity": "sha512-neJAj8GwF0e8EpycYIDFqEPcx9Qz4GUho20jWFR7YiFeXzF1YMLdxB36PypcTSPMA+4+LvgyMacYhlr18Zlymw==", + "license": "MIT", + "dependencies": { + "steno": "^4.0.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/lower-case": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", @@ -11323,6 +11962,15 @@ "node": ">=8.6" } }, + "node_modules/milliparsec": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/milliparsec/-/milliparsec-5.1.1.tgz", + "integrity": "sha512-jkEDaSWZp4/Q3vprqdqukBqUEyNNqC1pwTjZ5cp9YkaR1wv5fvTCd8VFsecbw7i8DNBGjzhJ83MDoPZlcTaPQg==", + "license": "MIT", + "engines": { + "node": ">=18.13 || >=19.20 || >=20" + } + }, "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -11433,6 +12081,15 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -14035,6 +14692,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regexparam": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/regexparam/-/regexparam-2.0.2.tgz", + "integrity": "sha512-A1PeDEYMrkLrfyOwv2jwihXbo9qxdGD3atBYQA9JJgreAx8/7rC6IUkWOw2NQlOxLp2wL0ifQbh1HuidDfYA6w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/regexpu-core": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", @@ -14345,6 +15011,15 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-array-concat": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", @@ -14872,6 +15547,20 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -14898,6 +15587,21 @@ "websocket-driver": "^0.7.4" } }, + "node_modules/sort-on": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/sort-on/-/sort-on-7.0.0.tgz", + "integrity": "sha512-e+4RRxt7jsWdGPp4H5PKOER/ELYlemNB1plvW686Qi3j4WVaCjCpro2zaTD7Cn0VtBImq/hg3x1JfovMNXXfJQ==", + "license": "MIT", + "dependencies": { + "dot-prop": "^10.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/source-list-map": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", @@ -15057,6 +15761,18 @@ "node": ">= 0.8" } }, + "node_modules/steno": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/steno/-/steno-4.0.2.tgz", + "integrity": "sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -15573,6 +16289,18 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "license": "MIT" }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -15941,6 +16669,15 @@ "node": ">=0.6" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/tough-cookie": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", @@ -15977,6 +16714,15 @@ "node": ">=8" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/tryer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz", diff --git a/package.json b/package.json index c2f58c4..28a1c0e 100644 --- a/package.json +++ b/package.json @@ -7,13 +7,16 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^13.5.0", + "concurrently": "^10.0.3", + "json-server": "^1.0.0-beta.15", "react": "^19.2.7", "react-dom": "^19.2.7", "react-scripts": "5.0.1", "web-vitals": "^2.1.4" }, + "proxy": "http://localhost:3001", "scripts": { - "start": "react-scripts start", + "start": "concurrently \"json-server --watch db.json --port 3001\" \"react-scripts start\"", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject" diff --git a/src/App.js b/src/App.js index 3784575..4f7c236 100644 --- a/src/App.js +++ b/src/App.js @@ -1,25 +1,2 @@ -import logo from './logo.svg'; -import './App.css'; - -function App() { - return ( -
-
- logo -

- Edit src/App.js and save to reload. -

- - Learn React - -
-
- ); -} - -export default App; +import PropertyManager from './property-manager'; +export default PropertyManager; diff --git a/src/property-manager.jsx b/src/property-manager.jsx new file mode 100644 index 0000000..2b0b59c --- /dev/null +++ b/src/property-manager.jsx @@ -0,0 +1,1261 @@ +import { useState, useEffect } from "react"; + +// ── Palette & design tokens ────────────────────────────────────────────────── +// Deep navy (#0F1B2D) ground, warm off-white (#F5F0E8) surface, +// gold accent (#C9A84C) for financial highlights, slate (#6B7A8D) for muted. +// Display: "Georgia" serif. Body/data: system-ui. Mono for $ figures. + +const COLORS = { + navy: "#0F1B2D", + navyMid: "#1A2E47", + gold: "#C9A84C", + goldLight: "#F0DFA0", + cream: "#F5F0E8", + white: "#FFFFFF", + slate: "#6B7A8D", + slateLight: "#E8ECF0", + red: "#C0392B", + green: "#1A7A4A", + greenLight: "#E8F5EE", + redLight: "#FDECEA", +}; + +const css = ` + @import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&display=swap'); + + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + + body { + font-family: system-ui, -apple-system, sans-serif; + background: ${COLORS.navy}; + color: ${COLORS.navyMid}; + min-height: 100vh; + } + + .app { display: flex; min-height: 100vh; } + + /* Sidebar */ + .sidebar { + width: 220px; min-width: 220px; + background: ${COLORS.navy}; + display: flex; flex-direction: column; + padding: 0 0 24px 0; + border-right: 1px solid rgba(201,168,76,0.15); + position: sticky; top: 0; height: 100vh; + } + .sidebar-logo { + padding: 28px 20px 20px; + border-bottom: 1px solid rgba(201,168,76,0.2); + margin-bottom: 12px; + } + .sidebar-logo h1 { + font-family: 'Playfair Display', Georgia, serif; + color: ${COLORS.gold}; + font-size: 1.2rem; + line-height: 1.3; + letter-spacing: 0.01em; + } + .sidebar-logo span { + color: ${COLORS.slate}; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + display: block; + margin-top: 2px; + } + .nav-item { + display: flex; align-items: center; gap: 10px; + padding: 10px 20px; + cursor: pointer; + font-size: 0.88rem; + color: rgba(245,240,232,0.6); + border-left: 3px solid transparent; + transition: all 0.15s; + user-select: none; + } + .nav-item:hover { color: ${COLORS.cream}; background: rgba(255,255,255,0.04); } + .nav-item.active { + color: ${COLORS.gold}; + border-left-color: ${COLORS.gold}; + background: rgba(201,168,76,0.08); + font-weight: 500; + } + .nav-icon { font-size: 1rem; width: 18px; text-align: center; } + + /* Main */ + .main { + flex: 1; + background: ${COLORS.cream}; + overflow-y: auto; + } + .page-header { + background: ${COLORS.white}; + padding: 24px 32px 20px; + border-bottom: 1px solid ${COLORS.slateLight}; + display: flex; align-items: center; justify-content: space-between; + } + .page-title { + font-family: 'Playfair Display', Georgia, serif; + font-size: 1.5rem; + color: ${COLORS.navy}; + } + .page-sub { font-size: 0.8rem; color: ${COLORS.slate}; margin-top: 2px; } + .page-content { padding: 28px 32px; } + + /* Cards */ + .card { + background: ${COLORS.white}; + border-radius: 8px; + padding: 24px; + border: 1px solid ${COLORS.slateLight}; + } + .card-title { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: ${COLORS.slate}; + margin-bottom: 8px; + } + + /* Stat grid */ + .stat-grid { display: grid; grid-template-columns: repeat(4,1fr); gap: 16px; margin-bottom: 28px; } + .stat-card { + background: ${COLORS.white}; + border-radius: 8px; + padding: 20px 24px; + border: 1px solid ${COLORS.slateLight}; + border-top: 3px solid transparent; + } + .stat-card.gold { border-top-color: ${COLORS.gold}; } + .stat-card.green { border-top-color: ${COLORS.green}; } + .stat-card.red { border-top-color: ${COLORS.red}; } + .stat-card.navy { border-top-color: ${COLORS.navyMid}; } + .stat-label { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.08em; color: ${COLORS.slate}; } + .stat-value { font-size: 1.7rem; font-weight: 700; color: ${COLORS.navy}; margin: 6px 0 2px; font-variant-numeric: tabular-nums; } + .stat-sub { font-size: 0.75rem; color: ${COLORS.slate}; } + + /* Tables */ + .table-wrap { overflow-x: auto; } + table { width: 100%; border-collapse: collapse; font-size: 0.86rem; } + thead th { + text-align: left; + padding: 10px 14px; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: ${COLORS.slate}; + background: ${COLORS.cream}; + border-bottom: 1px solid ${COLORS.slateLight}; + font-weight: 600; + } + tbody tr { border-bottom: 1px solid ${COLORS.slateLight}; transition: background 0.1s; } + tbody tr:hover { background: #fafaf8; } + tbody td { padding: 12px 14px; color: ${COLORS.navyMid}; } + .mono { font-variant-numeric: tabular-nums; font-family: 'Courier New', monospace; } + .income { color: ${COLORS.green}; font-weight: 600; } + .expense { color: ${COLORS.red}; font-weight: 600; } + + /* Badge */ + .badge { + display: inline-block; + padding: 2px 8px; + border-radius: 99px; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.04em; + } + .badge-green { background: ${COLORS.greenLight}; color: ${COLORS.green}; } + .badge-red { background: ${COLORS.redLight}; color: ${COLORS.red}; } + .badge-gold { background: #FEF8E6; color: #9A6F1A; } + .badge-slate { background: ${COLORS.slateLight}; color: ${COLORS.slate}; } + + /* Buttons */ + .btn { + display: inline-flex; align-items: center; gap: 6px; + padding: 9px 18px; + border-radius: 6px; + border: none; cursor: pointer; + font-size: 0.85rem; font-weight: 500; + transition: all 0.15s; + } + .btn-primary { background: ${COLORS.gold}; color: ${COLORS.navy}; } + .btn-primary:hover { background: #b8942e; } + .btn-outline { + background: transparent; + border: 1px solid ${COLORS.slateLight}; + color: ${COLORS.navyMid}; + } + .btn-outline:hover { border-color: ${COLORS.slate}; } + .btn-danger { background: ${COLORS.red}; color: white; } + .btn-sm { padding: 5px 11px; font-size: 0.78rem; } + + .btn-delete { + background: none; border: none; cursor: pointer; + color: ${COLORS.slate}; font-size: 1.1rem; line-height: 1; + padding: 2px 6px; border-radius: 4px; opacity: 0.5; + transition: opacity 0.15s, color 0.15s; + } + .btn-delete:hover { opacity: 1; color: ${COLORS.red}; } + + /* Form */ + .form-row { display: grid; gap: 16px; margin-bottom: 16px; } + .form-row.cols-2 { grid-template-columns: 1fr 1fr; } + .form-row.cols-3 { grid-template-columns: 1fr 1fr 1fr; } + .form-group { display: flex; flex-direction: column; gap: 5px; } + label { font-size: 0.78rem; font-weight: 600; color: ${COLORS.navy}; letter-spacing: 0.02em; } + input, select, textarea { + padding: 9px 12px; + border: 1px solid ${COLORS.slateLight}; + border-radius: 6px; + font-size: 0.875rem; + color: ${COLORS.navyMid}; + background: ${COLORS.white}; + outline: none; + transition: border-color 0.15s; + font-family: inherit; + } + input:focus, select:focus, textarea:focus { border-color: ${COLORS.gold}; box-shadow: 0 0 0 3px rgba(201,168,76,0.12); } + textarea { resize: vertical; min-height: 80px; } + + /* Modal */ + .modal-overlay { + position: fixed; inset: 0; + background: rgba(15,27,45,0.6); + display: flex; align-items: center; justify-content: center; + z-index: 100; padding: 20px; + backdrop-filter: blur(2px); + } + .modal { + background: ${COLORS.white}; + border-radius: 12px; + width: 100%; max-width: 600px; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 24px 60px rgba(0,0,0,0.25); + } + .modal-header { + padding: 22px 28px 18px; + border-bottom: 1px solid ${COLORS.slateLight}; + display: flex; justify-content: space-between; align-items: center; + } + .modal-header h2 { + font-family: 'Playfair Display', Georgia, serif; + font-size: 1.15rem; color: ${COLORS.navy}; + } + .modal-body { padding: 24px 28px; } + .modal-footer { + padding: 16px 28px 22px; + display: flex; justify-content: flex-end; gap: 10px; + border-top: 1px solid ${COLORS.slateLight}; + } + .close-btn { + background: none; border: none; cursor: pointer; + font-size: 1.3rem; color: ${COLORS.slate}; + line-height: 1; padding: 2px; + } + .close-btn:hover { color: ${COLORS.navy}; } + + /* Invoice preview */ + .invoice-preview { + background: ${COLORS.white}; + border: 1px solid ${COLORS.slateLight}; + border-radius: 8px; + padding: 32px; + font-size: 0.875rem; + } + .invoice-top { display: flex; justify-content: space-between; margin-bottom: 28px; } + .invoice-from h3 { + font-family: 'Playfair Display', Georgia, serif; + color: ${COLORS.navy}; font-size: 1.1rem; margin-bottom: 4px; + } + .invoice-from p, .invoice-to p { color: ${COLORS.slate}; font-size: 0.8rem; line-height: 1.7; } + .invoice-meta { text-align: right; } + .invoice-num { font-size: 1.5rem; font-weight: 700; color: ${COLORS.gold}; font-family: 'Playfair Display', Georgia, serif; } + .invoice-lines { width: 100%; border-collapse: collapse; margin: 20px 0; } + .invoice-lines th { background: ${COLORS.navy}; color: ${COLORS.gold}; padding: 8px 12px; text-align: left; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.06em; } + .invoice-lines td { padding: 10px 12px; border-bottom: 1px solid ${COLORS.slateLight}; } + .invoice-total { text-align: right; } + .invoice-total .total-line { display: flex; justify-content: flex-end; gap: 40px; margin-bottom: 6px; font-size: 0.85rem; color: ${COLORS.slate}; } + .invoice-total .grand { color: ${COLORS.navy}; font-weight: 700; font-size: 1.1rem; } + + /* Lease card */ + .lease-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px,1fr)); gap: 16px; } + .lease-card { + background: ${COLORS.white}; + border-radius: 8px; + border: 1px solid ${COLORS.slateLight}; + border-top: 4px solid ${COLORS.navy}; + padding: 20px; + } + .lease-card.expiring { border-top-color: ${COLORS.gold}; } + .lease-card.expired { border-top-color: ${COLORS.red}; } + .lease-card h3 { font-size: 1rem; font-weight: 600; color: ${COLORS.navy}; margin-bottom: 4px; } + .lease-detail { font-size: 0.8rem; color: ${COLORS.slate}; line-height: 1.9; } + .lease-detail strong { color: ${COLORS.navyMid}; font-weight: 600; } + + /* Divider */ + .divider { height: 1px; background: ${COLORS.slateLight}; margin: 24px 0; } + + /* Empty */ + .empty { text-align: center; padding: 48px 24px; color: ${COLORS.slate}; } + .empty-icon { font-size: 2.5rem; margin-bottom: 12px; } + .empty p { font-size: 0.875rem; } + + /* Tabs */ + .tabs { display: flex; gap: 4px; margin-bottom: 20px; background: ${COLORS.slateLight}; border-radius: 8px; padding: 4px; width: fit-content; } + .tab { padding: 7px 16px; border-radius: 6px; border: none; cursor: pointer; font-size: 0.82rem; font-weight: 500; background: none; color: ${COLORS.slate}; transition: all 0.15s; } + .tab.active { background: ${COLORS.white}; color: ${COLORS.navy}; box-shadow: 0 1px 3px rgba(0,0,0,0.08); } + + @media (max-width: 900px) { + .stat-grid { grid-template-columns: 1fr 1fr; } + .sidebar { width: 60px; min-width: 60px; } + .sidebar-logo, .nav-item span { display: none; } + .nav-icon { margin: 0 auto; } + .nav-item { justify-content: center; padding: 14px 0; } + } +`; + + +// ── Helpers ────────────────────────────────────────────────────────────────── +const fmt = (n) => new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", minimumFractionDigits: 0 }).format(n); +const today = () => new Date().toISOString().split("T")[0]; +const daysUntil = (d) => Math.ceil((new Date(d) - new Date()) / 86400000); + +const leaseStatus = (lease) => { + const d = daysUntil(lease.end); + if (d < 0) return "expired"; + if (d <= 60) return "expiring"; + return "active"; +}; + +const nextInvoiceNum = (invoices) => { + const max = invoices.reduce((m, i) => Math.max(m, parseInt(i.num.split("-")[1])), 0); + return `INV-${String(max + 1).padStart(3, "0")}`; +}; + +// ── Components ─────────────────────────────────────────────────────────────── + +function Modal({ title, onClose, onSave, children, wide }) { + return ( +
e.target === e.currentTarget && onClose()}> +
+
+

{title}

+ +
+
{children}
+ {onSave && ( +
+ + +
+ )} +
+
+ ); +} + +// ── Dashboard ──────────────────────────────────────────────────────────────── +function Dashboard({ transactions, invoices, leases, tenants }) { + const income = transactions.filter(t => t.type === "income").reduce((s, t) => s + t.amount, 0); + const expenses = transactions.filter(t => t.type === "expense").reduce((s, t) => s + t.amount, 0); + const net = income - expenses; + const pending = invoices.filter(i => i.status === "pending").reduce((s, i) => s + i.items.reduce((a, b) => a + b.rate * b.qty, 0), 0); + const expiringLeases = leases.filter(l => leaseStatus(l) === "expiring" || leaseStatus(l) === "expired"); + + const categories = {}; + transactions.filter(t => t.type === "expense").forEach(t => { categories[t.category] = (categories[t.category] || 0) + t.amount; }); + + return ( +
+
+
+
Total Income
+
{fmt(income)}
+
this period
+
+
+
Total Expenses
+
{fmt(expenses)}
+
this period
+
+
+
Net Operating Income
+
= 0 ? COLORS.green : COLORS.red }}>{fmt(net)}
+
income − expenses
+
+
+
Pending Invoices
+
{fmt(pending)}
+
{invoices.filter(i => i.status === "pending").length} invoice(s) outstanding
+
+
+ +
+
+
Recent Transactions
+
+ + + + {transactions.slice(-5).reverse().map(t => ( + + + + + + ))} + +
DateDescriptionAmount
{t.date}{t.description}{t.type === "income" ? "+" : "−"}{fmt(t.amount)}
+
+
+ +
+
Expense Breakdown
+ {Object.entries(categories).map(([cat, amt]) => ( +
+
+ {cat} + {fmt(amt)} +
+
+
+
+
+ ))} + {expiringLeases.length > 0 && ( + <> +
+
Lease Alerts
+ {expiringLeases.map(l => { + const t = tenants.find(t => String(t.id) === String(l.tenantId)); + const d = daysUntil(l.end); + return ( +
+ {t?.name} — {l.unit} + + {d < 0 ? `Expired ${Math.abs(d)}d ago` : `Expires in ${d}d`} + +
+ ); + })} + + )} +
+
+
+ ); +} + +// ── Transactions ───────────────────────────────────────────────────────────── +function Transactions({ transactions, onAddTransaction, onDeleteTransaction, tenants }) { + const [showModal, setShowModal] = useState(false); + const [tab, setTab] = useState("all"); + const [txPage, setTxPage] = useState(1); + const [form, setForm] = useState({ date: today(), type: "income", category: "Rent", description: "", amount: "", tenantId: "" }); + + const visible = tab === "all" ? transactions : transactions.filter(t => t.type === tab); + const sorted = [...visible].sort((a, b) => b.date.localeCompare(a.date)); + const PAGE_SIZE = 20; + const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE)); + const paginated = sorted.slice((txPage - 1) * PAGE_SIZE, txPage * PAGE_SIZE); + + const save = () => { + if (!form.description || !form.amount) return; + onAddTransaction({ ...form, amount: parseFloat(form.amount), tenantId: form.tenantId ? parseInt(form.tenantId) : null }); + setShowModal(false); + setForm({ date: today(), type: "income", category: "Rent", description: "", amount: "", tenantId: "" }); + }; + + const incCats = ["Rent", "Late Fee", "Security Deposit", "Parking", "Pet Fee", "Other Income"]; + const expCats = ["Repairs", "Maintenance", "Insurance", "Mortgage", "Taxes", "Utilities", "Management", "Landscaping", "Other Expense"]; + + return ( +
+
+ {["all", "income", "expense"].map(t => ( + + ))} +
+ +
+
+
Transaction Ledger
+ +
+
+ + + + + + {visible.length === 0 && ( + + )} + {paginated.map(t => { + const tenant = tenants.find(tn => String(tn.id) === String(t.tenantId)); + return ( + + + + + + + + + + ); + })} + +
DateTypeCategoryDescriptionTenantAmount
📋

No transactions yet

{t.date}{t.type}{t.category}{t.description}{tenant?.name || "—"} + {t.type === "income" ? "+" : "−"}{fmt(t.amount)} +
+
+ {totalPages > 1 && ( +
+ {sorted.length} transactions · page {txPage} of {totalPages} +
+ + {Array.from({ length: totalPages }, (_, i) => i + 1).map(n => ( + + ))} + +
+
+ )} +
+ + {showModal && ( + setShowModal(false)} onSave={save}> +
+
setForm(p => ({ ...p, date: e.target.value }))} />
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
setForm(p => ({ ...p, description: e.target.value }))} />
+
+
+
setForm(p => ({ ...p, amount: e.target.value }))} />
+
+
+ )} +
+ ); +} + +// ── Invoices ───────────────────────────────────────────────────────────────── +function InvoiceLineItem({ item, idx, onChange, onRemove }) { + return ( +
+ onChange(idx, "desc", e.target.value)} /> + onChange(idx, "qty", parseFloat(e.target.value) || 1)} style={{ textAlign: "center" }} /> + onChange(idx, "rate", parseFloat(e.target.value) || 0)} /> + +
+ ); +} + +function InvoicePreview({ inv, tenants, fromName }) { + const tenant = tenants.find(t => String(t.id) === String(inv.tenantId)); + const subtotal = inv.items.reduce((s, i) => s + i.qty * i.rate, 0); + return ( +
+
+
+

{fromName || "Kenji Morishige"}

+

Tax ID: 99-4686386
kenji@kenjim.com
408.813.3323

+
+
+
{inv.num || "INV-XXX"}
+

+ Issue date: {inv.date}
Due date: {inv.due} +

+
+
+
+
Bill To
+ {tenant?.name || "—"} +

{tenant?.unit}
{tenant?.email}

+
+ + + + {inv.items.map((item, i) => ( + + + + + + + ))} + +
DescriptionQtyRateTotal
{item.desc}{item.qty}{fmt(item.rate)}{fmt(item.qty * item.rate)}
+
+
Subtotal{fmt(subtotal)}
+
Total Due{fmt(subtotal)}
+
+
+ Please pay by {inv.due}. Questions? Contact kenji@kenjim.com or 408.813.3323. +
+
+ ); +} + +function Invoices({ invoices, onAddInvoice, onUpdateInvoice, onDeleteInvoice, tenants, leases }) { + const [showNew, setShowNew] = useState(false); + const [viewing, setViewing] = useState(null); + const [form, setForm] = useState({ tenantId: "", date: today(), due: "", items: [{ desc: "", qty: 1, rate: 0 }], status: "pending" }); + + const updateItem = (idx, key, val) => setForm(p => { const items = [...p.items]; items[idx] = { ...items[idx], [key]: val }; return { ...p, items }; }); + const addItem = () => setForm(p => ({ ...p, items: [...p.items, { desc: "", qty: 1, rate: 0 }] })); + const removeItem = (idx) => setForm(p => ({ ...p, items: p.items.filter((_, i) => i !== idx) })); + + const save = () => { + if (!form.tenantId || form.items.length === 0) return; + const newInv = { ...form, num: nextInvoiceNum(invoices), tenantId: parseInt(form.tenantId) }; + onAddInvoice(newInv); + setShowNew(false); + setForm({ tenantId: "", date: today(), due: "", items: [{ desc: "", qty: 1, rate: 0 }], status: "pending" }); + }; + + const markPaid = (id) => onUpdateInvoice(id, { status: "paid" }); + + const generateMonthly = () => { + const activeLease = leases.find(l => leaseStatus(l) === "active" || leaseStatus(l) === "expiring"); + if (!activeLease) { alert("No active lease found."); return; } + const tenant = tenants.find(t => String(t.id) === String(activeLease.tenantId)); + if (!tenant) { alert("Lease tenant not found."); return; } + + // Find the latest invoice month to determine next month + const tenantInvoices = invoices.filter(i => String(i.tenantId) === String(activeLease.tenantId)); + let nextMonth; + if (tenantInvoices.length > 0) { + const lastDate = tenantInvoices.map(i => i.date).sort().reverse()[0]; + const d = new Date(lastDate + "T00:00:00"); + d.setMonth(d.getMonth() + 1); + nextMonth = d; + } else { + nextMonth = new Date(); + nextMonth.setDate(1); + } + + const monthName = nextMonth.toLocaleString("en-US", { month: "long", year: "numeric" }); + const issueDate = nextMonth.toISOString().split("T")[0]; + const dueDate = new Date(nextMonth); + dueDate.setDate(5); + const dueDateStr = dueDate.toISOString().split("T")[0]; + + const newInv = { + num: nextInvoiceNum(invoices), + tenantId: activeLease.tenantId, + date: issueDate, + due: dueDateStr, + status: "pending", + items: [{ desc: `Monthly Rent — ${monthName}`, qty: 1, rate: activeLease.rent }], + }; + onAddInvoice(newInv); + }; + + const sendInvoiceEmail = (inv) => { + const tenant = tenants.find(t => String(t.id) === String(inv.tenantId)); + if (!tenant?.email) { alert("No email address on file for this tenant."); return; } + const total = inv.items.reduce((s, i) => s + i.qty * i.rate, 0); + const lineItems = inv.items.map(i => + ` ${i.desc.padEnd(40)} ${fmt(i.qty * i.rate).padStart(10)}` + ).join("\n"); + const subject = encodeURIComponent(`Invoice ${inv.num} — Due ${inv.due}`); + const body = encodeURIComponent( +`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + INVOICE +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +From: Kenji Morishige + Tax ID: 99-4686386 + kenji@kenjim.com | 408.813.3323 + +To: ${tenant.name} + ${tenant.unit} + ${tenant.email} + +────────────────────────────────────────── + Invoice #: ${inv.num} + Issue Date: ${inv.date} + Due Date: ${inv.due} +────────────────────────────────────────── + + CHARGES + +${lineItems} + +────────────────────────────────────────── + TOTAL DUE ${fmt(total).padStart(10)} +────────────────────────────────────────── + +Please remit payment by ${inv.due}. +Questions? Reply to this email or call 408.813.3323. + +Thank you, +Kenji Morishige` + ); + window.open(`mailto:${tenant.email}?cc=kenji@kenjim.com&subject=${subject}&body=${body}`); + }; + + const sendPaymentEmail = (inv) => { + const tenant = tenants.find(t => String(t.id) === String(inv.tenantId)); + if (!tenant?.email) { alert("No email address on file for this tenant."); return; } + const total = inv.items.reduce((s, i) => s + i.qty * i.rate, 0); + const subject = encodeURIComponent(`Payment Received — ${inv.num}`); + const body = encodeURIComponent( +`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + PAYMENT CONFIRMATION +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Hi ${tenant.name}, + +This is to confirm that your payment has been received and applied to the following invoice. + +────────────────────────────────────────── + Invoice #: ${inv.num} + Property: ${tenant.unit} + Amount Paid: ${fmt(total)} + Date Paid: ${today()} + Status: PAID IN FULL ✓ +────────────────────────────────────────── + +Your account is up to date for this invoice. Thank you for your payment! + +If you have any questions, please contact: + kenji@kenjim.com | 408.813.3323 + +Thank you, +Kenji Morishige` + ); + window.open(`mailto:${tenant.email}?cc=kenji@kenjim.com&subject=${subject}&body=${body}`); + }; + + return ( +
+
+ + +
+ +
+
+ + + + + + {invoices.length === 0 && ( + + )} + {[...invoices].reverse().map(inv => { + const tenant = tenants.find(t => String(t.id) === String(inv.tenantId)); + const total = inv.items.reduce((s, i) => s + i.qty * i.rate, 0); + return ( + + + + + + + + + + ); + })} + +
Invoice #TenantIssue DateDue DateAmountStatusActions
🧾

No invoices yet

{inv.num}{tenant?.name}{inv.date}{inv.due}{fmt(total)}{inv.status} + + + + {inv.status === "pending" && ( + + )} + +
+
+
+ + {showNew && ( + setShowNew(false)} onSave={save} wide> +
+
+
+
+ + +
+
+
+
setForm(p => ({ ...p, date: e.target.value }))} />
+
setForm(p => ({ ...p, due: e.target.value }))} />
+
+
+
+ Description + Qty + Rate ($) + +
+ {form.items.map((item, idx) => ( + + ))} + +
+
+
+
Preview
+ +
+
+
+ )} + + {viewing && ( + setViewing(null)}> + + + )} +
+ ); +} + +// ── Leases ─────────────────────────────────────────────────────────────────── +function Leases({ leases, onAddLease, onDeleteLease, tenants }) { + const [showModal, setShowModal] = useState(false); + const [form, setForm] = useState({ tenantId: "", unit: "", rent: "", deposit: "", start: today(), end: "" }); + + const save = () => { + if (!form.tenantId || !form.unit || !form.rent) return; + onAddLease({ ...form, tenantId: parseInt(form.tenantId), rent: parseFloat(form.rent), deposit: parseFloat(form.deposit || 0), status: "active" }); + setShowModal(false); + setForm({ tenantId: "", unit: "", rent: "", deposit: "", start: today(), end: "" }); + }; + + return ( +
+
+
+ +
+ +
+ {leases.length === 0 && ( +
📄

No leases yet

+ )} + {leases.map(lease => { + const tenant = tenants.find(t => String(t.id) === String(lease.tenantId)); + const st = leaseStatus(lease); + const d = daysUntil(lease.end); + return ( +
+
+
+

{tenant?.name || "Unknown Tenant"}

+
{lease.unit}
+
+
+ + {st === "active" ? "Active" : st === "expiring" ? `Expires in ${d}d` : "Expired"} + + +
+
+
+
Monthly Rent: {fmt(lease.rent)}
+
Security Deposit: {fmt(lease.deposit)}
+
Lease Start: {lease.start}
+
Lease End: {lease.end}
+ {tenant && <>
Email: {tenant.email}
Phone: {tenant.phone}
} +
+
+ ); + })} +
+ + {showModal && ( + setShowModal(false)} onSave={save}> +
+
+ + +
+
+
+
setForm(p => ({ ...p, unit: e.target.value }))} />
+
setForm(p => ({ ...p, rent: e.target.value }))} />
+
+
+
setForm(p => ({ ...p, deposit: e.target.value }))} />
+
+
+
+
setForm(p => ({ ...p, start: e.target.value }))} />
+
setForm(p => ({ ...p, end: e.target.value }))} />
+
+ + )} +
+ ); +} + +// ── Tenants ────────────────────────────────────────────────────────────────── +function EditableCell({ value, onSave, type = "text" }) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(value); + + const commit = () => { + setEditing(false); + if (draft !== value) onSave(draft); + }; + + if (editing) { + return ( + setDraft(e.target.value)} + onBlur={commit} + onKeyDown={e => { if (e.key === "Enter") commit(); if (e.key === "Escape") { setDraft(value); setEditing(false); } }} + style={{ width: "100%", padding: "3px 6px", fontSize: "inherit", border: `1px solid ${COLORS.gold}`, borderRadius: 4, outline: "none" }} + /> + ); + } + + return ( + { setDraft(value); setEditing(true); }} + title="Click to edit" + style={{ cursor: "text", color: COLORS.slate, borderBottom: `1px dashed ${COLORS.slateLight}`, paddingBottom: 1 }} + > + {value || } + + ); +} + +function Tenants({ tenants, onAddTenant, onUpdateTenant, onDeleteTenant }) { + const [showModal, setShowModal] = useState(false); + const [form, setForm] = useState({ name: "", unit: "", email: "", phone: "" }); + + const save = () => { + if (!form.name || !form.unit) return; + onAddTenant(form); + setShowModal(false); + setForm({ name: "", unit: "", email: "", phone: "" }); + }; + + return ( +
+
+
+ +
+
+
+ + + + {tenants.length === 0 && ( + + )} + {tenants.map(t => ( + + + + + + + + ))} + +
NameUnitEmailPhone
👤

No tenants yet

{t.name}{t.unit} onUpdateTenant(t.id, { email: val })} /> onUpdateTenant(t.id, { phone: val })} />
+
+
+ {showModal && ( + setShowModal(false)} onSave={save}> +
+
setForm(p => ({ ...p, name: e.target.value }))} />
+
setForm(p => ({ ...p, unit: e.target.value }))} />
+
+
+
setForm(p => ({ ...p, email: e.target.value }))} />
+
setForm(p => ({ ...p, phone: e.target.value }))} />
+
+
+ )} +
+ ); +} + +// ── Property P&L ───────────────────────────────────────────────────────────── +function PropertyView({ property, transactions, leases, invoices, tenants }) { + const [yearFilter, setYearFilter] = useState("all"); + + const propTx = transactions.filter(t => String(t.propertyId) === String(property.id)); + const years = [...new Set(propTx.map(t => t.date.slice(0, 4)))].sort().reverse(); + + const filtered = yearFilter === "all" ? propTx : propTx.filter(t => t.date.startsWith(yearFilter)); + const income = filtered.filter(t => t.type === "income").reduce((s, t) => s + t.amount, 0); + const expenses = filtered.filter(t => t.type === "expense").reduce((s, t) => s + t.amount, 0); + const noi = income - expenses; + + // Expenses by category + const byCategory = filtered + .filter(t => t.type === "expense") + .reduce((acc, t) => { acc[t.category] = (acc[t.category] || 0) + t.amount; return acc; }, {}); + const catEntries = Object.entries(byCategory).sort((a, b) => b[1] - a[1]); + const maxCat = catEntries[0]?.[1] || 1; + + const activeLease = leases.find(l => String(l.propertyId) === String(property.id) && (leaseStatus(l) === "active" || leaseStatus(l) === "expiring")); + const tenant = activeLease ? tenants.find(t => String(t.id) === String(activeLease.tenantId)) : null; + const pendingInvoices = invoices.filter(i => String(i.propertyId) === String(property.id) && i.status === "pending"); + const pendingTotal = pendingInvoices.reduce((s, i) => s + i.items.reduce((ss, li) => ss + li.qty * li.rate, 0), 0); + + return ( +
+ {/* Property Card */} +
+
+
+
+ {property.address} +
+
{property.city}, {property.state} {property.zip}  ·  {property.type}  ·  Since {property.purchaseYear}
+
+
+ {tenant ? ( + <> +
Current Tenant
+
{tenant.name}
+
{fmt(activeLease.rent)}/mo · Ends {activeLease.end}
+ + ) : Vacant} +
+
+ {pendingTotal > 0 && ( +
+ Outstanding balance: {fmt(pendingTotal)} across {pendingInvoices.length} pending invoice{pendingInvoices.length !== 1 ? "s" : ""} +
+ )} +
+ + {/* Year filter + P&L stats */} +
+
+ + {years.map(y => )} +
+
+ +
+
Total Income
{fmt(income)}
+
Total Expenses
{fmt(expenses)}
+
Net Operating Income
= 0 ? COLORS.green : COLORS.red }}>{fmt(noi)}
+
Transactions
{filtered.length}
+
+ + {/* Expense breakdown */} + {catEntries.length > 0 && ( +
+
Expense Breakdown
+ {catEntries.map(([cat, amt]) => ( +
+
+ {cat}{fmt(amt)} +
+
+
+
+
+ ))} +
+ )} + + {/* Transaction list */} +
+
Transaction History
+
+ + + + {filtered.length === 0 && } + {[...filtered].sort((a, b) => b.date.localeCompare(a.date)).map(t => ( + + + + + + + + ))} + +
DateTypeCategoryDescriptionAmount

No transactions for this period

{t.date}{t.type}{t.category}{t.description}{t.type === "income" ? "+" : "−"}{fmt(t.amount)}
+
+
+
+ ); +} + +// ── App Shell ──────────────────────────────────────────────────────────────── +const NAV = [ + { id: "dashboard", icon: "📊", label: "Dashboard" }, + { id: "properties", icon: "🏠", label: "Properties" }, + { id: "transactions", icon: "💳", label: "Transactions" }, + { id: "invoices", icon: "🧾", label: "Invoices" }, + { id: "leases", icon: "📄", label: "Leases" }, + { id: "tenants", icon: "👤", label: "Tenants" }, +]; + +const api = { + getAll: (resource) => fetch(`/${resource}`).then(r => r.json()), + post: (resource, body) => fetch(`/${resource}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }).then(r => r.json()), + patch: (resource, id, body) => fetch(`/${resource}/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }).then(r => r.json()), + delete: (resource, id) => fetch(`/${resource}/${id}`, { method: "DELETE" }), +}; + +export default function App() { + const [page, setPage] = useState("dashboard"); + const [properties, setProperties] = useState([]); + const [tenants, setTenants] = useState([]); + const [leases, setLeases] = useState([]); + const [transactions, setTransactions] = useState([]); + const [invoices, setInvoices] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + Promise.all([ + api.getAll("properties"), + api.getAll("tenants"), + api.getAll("leases"), + api.getAll("transactions"), + api.getAll("invoices"), + ]).then(([props, t, l, tr, inv]) => { + setProperties(props); + setTenants(t); + setLeases(l); + setTransactions(tr); + setInvoices(inv); + setLoading(false); + }); + }, []); + + const onAddTenant = (data) => + api.post("tenants", data).then(t => setTenants(prev => [...prev, t])); + + const onAddLease = (data) => + api.post("leases", data).then(l => setLeases(prev => [...prev, l])); + + const onAddTransaction = (data) => + api.post("transactions", data).then(t => setTransactions(prev => [...prev, t])); + + const onAddInvoice = (data) => + api.post("invoices", data).then(inv => setInvoices(prev => [...prev, inv])); + + const onUpdateInvoice = (id, changes) => + api.patch("invoices", id, changes).then(updated => setInvoices(prev => prev.map(i => i.id === id ? updated : i))); + + const onUpdateTenant = (id, changes) => + api.patch("tenants", id, changes).then(updated => setTenants(prev => prev.map(t => t.id === id ? updated : t))); + + const onDeleteTenant = (id) => + api.delete("tenants", id).then(() => setTenants(prev => prev.filter(t => t.id !== id))); + + const onDeleteLease = (id) => + api.delete("leases", id).then(() => setLeases(prev => prev.filter(l => l.id !== id))); + + const onDeleteTransaction = (id) => + api.delete("transactions", id).then(() => setTransactions(prev => prev.filter(t => t.id !== id))); + + const onDeleteInvoice = (id) => + api.delete("invoices", id).then(() => setInvoices(prev => prev.filter(i => i.id !== id))); + + const PAGE_TITLES = { + dashboard: { title: "Dashboard", sub: "Overview of your rental portfolio" }, + properties: { title: "Properties", sub: "Property P&L and expense history" }, + transactions:{ title: "Transactions", sub: "Income & expense ledger" }, + invoices: { title: "Invoices", sub: "Create and track tenant invoices" }, + leases: { title: "Lease Contracts", sub: "Track lease terms and renewals" }, + tenants: { title: "Tenants", sub: "Tenant contact directory" }, + }; + + return ( + <> + +
+
+
+

Property
Manager

+ Kenji Morishige +
+ {NAV.map(n => ( +
setPage(n.id)}> + {n.icon} + {n.label} +
+ ))} +
+ +
+
+
+
{PAGE_TITLES[page].title}
+
{PAGE_TITLES[page].sub}
+
+
+
+ {loading ? ( +
Loading…
+ ) : ( + <> + {page === "dashboard" && } + {page === "properties" && properties.map(p => )} + {page === "transactions"&& } + {page === "invoices" && } + {page === "leases" && } + {page === "tenants" && } + + )} +
+
+
+ + ); +}