Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f865837b27 |
@@ -17,6 +17,11 @@ DELIVERY_MAX_DEVICES=2
|
|||||||
# accounts with `npm run seed`; public signup refuses these addresses.
|
# accounts with `npm run seed`; public signup refuses these addresses.
|
||||||
PLATFORM_ADMIN_EMAILS=
|
PLATFORM_ADMIN_EMAILS=
|
||||||
|
|
||||||
|
# ---- Invite links ----
|
||||||
|
# Public base URL used in staff invite links. Optional: defaults to the
|
||||||
|
# address the request came in on.
|
||||||
|
APP_URL=
|
||||||
|
|
||||||
# ---- M-Pesa (optional, off for the pilot) ----
|
# ---- M-Pesa (optional, off for the pilot) ----
|
||||||
# M-Pesa STK Push is out of scope for the pilot. While this is false every
|
# M-Pesa STK Push is out of scope for the pilot. While this is false every
|
||||||
# /api/mpesa route returns 404 and the Daraja variables below are ignored.
|
# /api/mpesa route returns 404 and the Daraja variables below are ignored.
|
||||||
|
|||||||
+38
-7
@@ -76,7 +76,7 @@ the decisions taken along the way, and what is still open.
|
|||||||
sales from the old version are sent on the next sync.
|
sales from the old version are sent on the next sync.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
- `npm test` runs 37 API tests against an in-memory MongoDB, covering role
|
- `npm test` runs 50 API tests against an in-memory MongoDB, covering role
|
||||||
enforcement, tenant isolation for sales, stock and transfers, suspension,
|
enforcement, tenant isolation for sales, stock and transfers, suspension,
|
||||||
the sync protocol (including a 120-sale backlog replayed twice), and the
|
the sync protocol (including a 120-sale backlog replayed twice), and the
|
||||||
business day.
|
business day.
|
||||||
@@ -84,9 +84,38 @@ the decisions taken along the way, and what is still open.
|
|||||||
in, stop the server, reload the terminal, record two sales, restart the
|
in, stop the server, reload the terminal, record two sales, restart the
|
||||||
server, confirm they sync once and deduct stock.
|
server, confirm they sync once and deduct stock.
|
||||||
|
|
||||||
|
## Follow-ups from the 11 October 2026 test report
|
||||||
|
|
||||||
|
The initial test report (run on the build before these changes) raised
|
||||||
|
seven findings and a retest plan. All of them are addressed on
|
||||||
|
`fix/test-report-followups`:
|
||||||
|
|
||||||
|
| Finding | What changed |
|
||||||
|
|---|---|
|
||||||
|
| F-01 Trial allows one branch | A business on its free trial may run **2 active branches** whatever its tier (`TRIAL_MAX_BRANCHES` in `src/lib/tiers.ts`). After the trial the tier's limit applies to adding branches. |
|
||||||
|
| F-02 No current stock | Done in the previous change (stock ledger). Purchases recorded before the ledger existed are added by `npm run backfill:stock`. |
|
||||||
|
| F-03 Reversed report dates | Refused in the page and by the API (400). The page labels which period the cards show. Report dates are Nairobi business days. |
|
||||||
|
| F-04 No way back for deactivated products | Products page lists deactivated products under a toggle, with Reactivate. Deactivating warns about stock still on hand; that stock stays on the Stock page and can be transferred or counted out. |
|
||||||
|
| F-05 Eggs shown as litres | Products carry a unit (L, ml, kg, g, pcs, tray, pack), chosen when the product is created and shown everywhere. |
|
||||||
|
| F-06 No branch or staff removal | Staff: deactivate/reactivate (previous change). Branches: rename and deactivate/reactivate on the Branches page. A branch can only be deactivated once it has no stock and no active staff; its history stays in reports. |
|
||||||
|
| F-07 Purchases shown as cost of goods | Cost of goods sold is now units sold x the product's average buying price. Purchases in the period are shown separately as "Stock purchased", with a "Cash surplus" line. |
|
||||||
|
| STA-04 No invites | Staff and new branch managers can be invited: the owner or manager gets a one-time link (7 days) to send, and the person sets their own password. A new link can be made from Staff. |
|
||||||
|
| CAT-12 Zero price, duplicates | Prices must be above zero; product names are unique per business, ignoring case. |
|
||||||
|
| OFF-02 Local stock deduction | The till shows each product's stock and deducts every sale immediately, online or offline. |
|
||||||
|
| POS-02 / INV-05 Oversell, cancel | The till warns before selling more than the recorded stock and asks to confirm (it does not block, because the sale may already have happened). A "Clear sale" button empties the cart. |
|
||||||
|
|
||||||
|
`tests/retest-plan.test.ts` runs the report's completion and retest plan
|
||||||
|
(online sale, transfer, two offline sales replayed twice, a Branch B sale)
|
||||||
|
and checks every balance and total it predicts.
|
||||||
|
|
||||||
|
### Before re-running the test report's plan on ZERRO
|
||||||
|
1. Deploy, then run `npm run backfill:stock` once so the existing test
|
||||||
|
purchases count as stock. Run it **before** anyone records an opening
|
||||||
|
count, or those units would be counted twice.
|
||||||
|
2. ZERRO is trialling, so it can now add Branch B without a plan change.
|
||||||
|
3. Create the manager and cashier logins with invite links from Staff.
|
||||||
|
|
||||||
## Still open
|
## Still open
|
||||||
- **Removing a branch.** Branches can be renamed but not removed. Someone
|
|
||||||
needs to decide what happens to a removed branch's stock and history.
|
|
||||||
- **Setting branch prices.** Branch price overrides are applied everywhere,
|
- **Setting branch prices.** Branch price overrides are applied everywhere,
|
||||||
but there is no screen or API to set them yet.
|
but there is no screen or API to set them yet.
|
||||||
- **Global email and phone uniqueness.** One person cannot work for two
|
- **Global email and phone uniqueness.** One person cannot work for two
|
||||||
@@ -96,9 +125,11 @@ the decisions taken along the way, and what is still open.
|
|||||||
route.
|
route.
|
||||||
- **Concurrent transfers.** Two transfers submitted at the same moment
|
- **Concurrent transfers.** Two transfers submitted at the same moment
|
||||||
could together send more than the branch holds.
|
could together send more than the branch holds.
|
||||||
- **Cash-basis profit.** The profit report counts all purchases in the
|
- **Cost of goods is an average.** It uses each product's average buying
|
||||||
period as cost of goods.
|
price across the business, not first-in-first-out per branch. Units sold
|
||||||
|
with no purchase on record are counted and flagged, but have no cost.
|
||||||
- **M-Pesa, if turned on.** Only one product per payment. A prompt that
|
- **M-Pesa, if turned on.** Only one product per payment. A prompt that
|
||||||
times out still needs a manual check before taking cash.
|
times out still needs a manual check before taking cash.
|
||||||
- **Not modelled yet:** invites and email verification, empty-bottle and
|
- **Not modelled yet:** email verification at signup, sending invites by
|
||||||
deposit tracking, home-screen icons for the PWA.
|
email (links are shared by hand, e.g. WhatsApp), empty-bottle and deposit
|
||||||
|
tracking, home-screen icons for the PWA.
|
||||||
|
|||||||
+2
-1
@@ -10,7 +10,8 @@
|
|||||||
"seed": "tsx scripts/seed.ts",
|
"seed": "tsx scripts/seed.ts",
|
||||||
"migrate:multitenant": "tsx scripts/migrate-multitenant.ts",
|
"migrate:multitenant": "tsx scripts/migrate-multitenant.ts",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"backfill:stock": "tsx scripts/backfill-stock-from-purchases.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import dotenv from "dotenv";
|
||||||
|
import path from "path";
|
||||||
|
dotenv.config({ path: path.resolve(__dirname, "../.env.local") });
|
||||||
|
|
||||||
|
import mongoose from "mongoose";
|
||||||
|
import { connectDb } from "../src/lib/server/db";
|
||||||
|
import { backfillPurchaseMovements } from "../src/lib/server/stock";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-off (safe to re-run): purchases recorded before the stock ledger
|
||||||
|
* existed did not add stock. This adds the missing PURCHASE movements so
|
||||||
|
* each branch's stock starts from what it actually bought.
|
||||||
|
*
|
||||||
|
* npm run backfill:stock
|
||||||
|
*
|
||||||
|
* Run it once after deploying the stock ledger, BEFORE branches record
|
||||||
|
* opening counts — a count recorded first already includes those units,
|
||||||
|
* and backfilling afterwards would add them a second time.
|
||||||
|
*/
|
||||||
|
async function main() {
|
||||||
|
await connectDb();
|
||||||
|
const added = await backfillPurchaseMovements();
|
||||||
|
console.log(`Added ${added} purchase stock movement(s).`);
|
||||||
|
await mongoose.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("backfill failed:", err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
+145
-16
@@ -27,6 +27,12 @@ export default function BranchesPage() {
|
|||||||
const [mgrEmail, setMgrEmail] = useState("");
|
const [mgrEmail, setMgrEmail] = useState("");
|
||||||
const [mgrPhone, setMgrPhone] = useState("");
|
const [mgrPhone, setMgrPhone] = useState("");
|
||||||
const [mgrPassword, setMgrPassword] = useState("");
|
const [mgrPassword, setMgrPassword] = useState("");
|
||||||
|
const [inviteLink, setInviteLink] = useState<{ name: string; url: string } | null>(null);
|
||||||
|
|
||||||
|
// Inline rename.
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editName, setEditName] = useState("");
|
||||||
|
const [editCity, setEditCity] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -52,7 +58,7 @@ export default function BranchesPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function loadBranches() {
|
async function loadBranches() {
|
||||||
const result = await apiFetch<{ branches: Branch[] }>("/api/branches");
|
const result = await apiFetch<{ branches: Branch[] }>("/api/branches?includeInactive=1");
|
||||||
setBranches(result.branches);
|
setBranches(result.branches);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,28 +77,29 @@ export default function BranchesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (withManager) {
|
if (withManager) {
|
||||||
if (!mgrName.trim() || !mgrEmail.trim() || !mgrPhone.trim() || !mgrPassword) {
|
if (!mgrName.trim() || !mgrEmail.trim() || !mgrPhone.trim()) {
|
||||||
setError("Fill in the manager's name, email, phone and password — or switch off the manager login.");
|
setError("Fill in the manager's name, email and phone — or switch off the manager login.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (mgrPassword.length < 8) {
|
if (mgrPassword && mgrPassword.length < 8) {
|
||||||
setError("The manager password must be at least 8 characters.");
|
setError("The manager password must be at least 8 characters, or leave it blank to send an invite link.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
body.manager = {
|
body.manager = {
|
||||||
name: mgrName.trim(),
|
name: mgrName.trim(),
|
||||||
email: mgrEmail.trim(),
|
email: mgrEmail.trim(),
|
||||||
phoneNumber: mgrPhone.trim(),
|
phoneNumber: mgrPhone.trim(),
|
||||||
password: mgrPassword,
|
password: mgrPassword || undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await apiFetch("/api/branches", {
|
const result = await apiFetch<{ inviteUrl?: string | null }>("/api/branches", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
setInviteLink(result.inviteUrl ? { name: mgrName.trim(), url: result.inviteUrl } : null);
|
||||||
if (withManager) {
|
if (withManager) {
|
||||||
show(branchName.trim() + " created with manager login for " + mgrEmail.trim(), "success");
|
show(branchName.trim() + " created with manager login for " + mgrEmail.trim(), "success");
|
||||||
} else {
|
} else {
|
||||||
@@ -113,6 +120,46 @@ export default function BranchesPage() {
|
|||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveRename(id: string) {
|
||||||
|
if (!editName.trim() || !editCity.trim()) {
|
||||||
|
setError("Enter both a branch name and a city.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apiFetch("/api/branches/" + id, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ branchName: editName.trim(), locationCity: editCity.trim() }),
|
||||||
|
});
|
||||||
|
show("Branch updated", "success");
|
||||||
|
setEditingId(null);
|
||||||
|
await loadBranches();
|
||||||
|
} catch (err) {
|
||||||
|
show(err instanceof ApiError ? err.message : "Could not update branch", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleBranch(b: Branch) {
|
||||||
|
const deactivating = b.isActive !== false;
|
||||||
|
if (
|
||||||
|
deactivating &&
|
||||||
|
!window.confirm(
|
||||||
|
`Deactivate ${b.branchName}? Its sales and stock history stay in your reports, but it takes no new staff, stock or sales. You can reactivate it later.`
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apiFetch("/api/branches/" + b._id, { method: "PATCH", body: JSON.stringify({ isActive: !deactivating }) });
|
||||||
|
show(`${b.branchName} ${deactivating ? "deactivated" : "reactivated"}`, "success");
|
||||||
|
await loadBranches();
|
||||||
|
} catch (err) {
|
||||||
|
// e.g. stock or staff still at the branch, or the plan's branch limit.
|
||||||
|
const message = err instanceof ApiError ? err.message : "Could not update branch";
|
||||||
|
setError(message);
|
||||||
|
show(message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!me) {
|
if (!me) {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-sand-50 pb-12">
|
<main className="min-h-screen bg-sand-50 pb-12">
|
||||||
@@ -150,6 +197,33 @@ export default function BranchesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{inviteLink && (
|
||||||
|
<div className="rounded-xl2 border border-confirm-500/30 bg-confirm-500/10 p-4 text-sm text-depth-800">
|
||||||
|
<p className="font-medium">Send this link to {inviteLink.name} so they can set their password.</p>
|
||||||
|
<p className="mt-1 text-xs text-depth-500">It works once and expires in 7 days. You can make a new one from Staff.</p>
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
value={inviteLink.url}
|
||||||
|
onFocus={(e) => e.target.select()}
|
||||||
|
aria-label="Invite link"
|
||||||
|
className="mt-3 w-full rounded-lg border border-depth-200 bg-white px-3 py-2 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
<a
|
||||||
|
href={`https://wa.me/?text=${encodeURIComponent(`Set your Vessel password here: ${inviteLink.url}`)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="rounded-lg border border-depth-200 bg-white px-3 py-1.5 text-xs font-semibold text-depth-700"
|
||||||
|
>
|
||||||
|
Share on WhatsApp
|
||||||
|
</a>
|
||||||
|
<button onClick={() => setInviteLink(null)} className="px-2 text-xs text-depth-500 underline">
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<SkeletonList rows={3} />
|
<SkeletonList rows={3} />
|
||||||
) : (
|
) : (
|
||||||
@@ -175,12 +249,44 @@ export default function BranchesPage() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-depth-100">
|
<ul className="divide-y divide-depth-100">
|
||||||
{branches.map((b) => (
|
{branches.map((b) => {
|
||||||
<li key={b._id} className="flex items-center justify-between px-4 py-3 animate-fade-up">
|
const active = b.isActive !== false;
|
||||||
<div>
|
return (
|
||||||
<p className="font-medium text-depth-900">{b.branchName}</p>
|
<li key={b._id} className="flex flex-wrap items-center justify-between gap-3 px-4 py-3 animate-fade-up">
|
||||||
|
{editingId === b._id ? (
|
||||||
|
<div className="flex flex-1 flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
aria-label="Branch name"
|
||||||
|
className="min-w-0 flex-1 rounded-lg border border-depth-200 px-2 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={editCity}
|
||||||
|
onChange={(e) => setEditCity(e.target.value)}
|
||||||
|
aria-label="City"
|
||||||
|
className="min-w-0 flex-1 rounded-lg border border-depth-200 px-2 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
<button onClick={() => saveRename(b._id)} className="rounded-lg bg-flow-500 px-3 py-1 text-sm font-semibold text-depth-950">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setEditingId(null)} className="text-sm text-depth-500 underline">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={active ? "" : "opacity-50"}>
|
||||||
|
<p className="font-medium text-depth-900">
|
||||||
|
{b.branchName}
|
||||||
|
{!active && <span className="ml-2 text-xs font-normal text-alert-600">Deactivated</span>}
|
||||||
|
</p>
|
||||||
<p className="text-xs text-depth-500">{b.locationCity}</p>
|
<p className="text-xs text-depth-500">{b.locationCity}</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{editingId !== b._id && (
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||||
|
{active && (
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
"rounded-full px-2.5 py-1 text-xs font-medium " +
|
"rounded-full px-2.5 py-1 text-xs font-medium " +
|
||||||
@@ -189,8 +295,31 @@ export default function BranchesPage() {
|
|||||||
>
|
>
|
||||||
{b.managerId ? "Has manager" : "No manager yet"}
|
{b.managerId ? "Has manager" : "No manager yet"}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingId(b._id);
|
||||||
|
setEditName(b.branchName);
|
||||||
|
setEditCity(b.locationCity);
|
||||||
|
}}
|
||||||
|
className="rounded-lg border border-depth-200 px-2.5 py-1 text-xs font-medium text-depth-700"
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleBranch(b)}
|
||||||
|
className={
|
||||||
|
"rounded-lg border px-2.5 py-1 text-xs font-medium " +
|
||||||
|
(active ? "border-alert-500/40 text-alert-600" : "border-confirm-500/40 text-confirm-600")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{active ? "Deactivate" : "Reactivate"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -238,8 +367,8 @@ export default function BranchesPage() {
|
|||||||
Create this branch's manager login now
|
Create this branch's manager login now
|
||||||
</span>
|
</span>
|
||||||
<span className="block text-xs text-depth-500">
|
<span className="block text-xs text-depth-500">
|
||||||
The manager signs in with this email & password, then adds their own delivery
|
Leave the password blank to get an invite link the manager uses to choose their own.
|
||||||
staff. You can leave this off and assign a manager later from Staff.
|
They then add their own delivery staff. You can also assign a manager later from Staff.
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -285,14 +414,14 @@ export default function BranchesPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="mgr-password">
|
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="mgr-password">
|
||||||
Temporary password
|
Password (optional)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="mgr-password"
|
id="mgr-password"
|
||||||
type="text"
|
type="text"
|
||||||
value={mgrPassword}
|
value={mgrPassword}
|
||||||
onChange={(e) => setMgrPassword(e.target.value)}
|
onChange={(e) => setMgrPassword(e.target.value)}
|
||||||
placeholder="At least 8 characters"
|
placeholder="Blank = send an invite link"
|
||||||
className="tap-target w-full rounded-xl border border-depth-200 bg-white px-3"
|
className="tap-target w-full rounded-xl border border-depth-200 bg-white px-3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export default function AdminDashboardPage() {
|
|||||||
Stock
|
Stock
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/admin/products" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
|
<Link href="/admin/products" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
|
||||||
Sizes
|
Products
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/admin/staff" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
|
<Link href="/admin/staff" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
|
||||||
Staff
|
Staff
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import { useEffect, useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { apiFetch, ApiError } from "@/lib/api";
|
import { apiFetch, ApiError } from "@/lib/api";
|
||||||
import { AuthUser, Product } from "@/lib/types";
|
import { AuthUser, Product, StockLevel } from "@/lib/types";
|
||||||
|
import { PRODUCT_UNITS, UNIT_LABELS, formatSize, type ProductUnit } from "@/lib/units";
|
||||||
import { useToast } from "@/components/ui/Toast";
|
import { useToast } from "@/components/ui/Toast";
|
||||||
import { SkeletonList } from "@/components/ui/Skeleton";
|
import { SkeletonList } from "@/components/ui/Skeleton";
|
||||||
import EmptyState from "@/components/ui/EmptyState";
|
import EmptyState from "@/components/ui/EmptyState";
|
||||||
import ConfirmButton from "@/components/ui/ConfirmButton";
|
|
||||||
|
|
||||||
export default function ProductsPage() {
|
export default function ProductsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -21,7 +21,9 @@ export default function ProductsPage() {
|
|||||||
|
|
||||||
const [label, setLabel] = useState("");
|
const [label, setLabel] = useState("");
|
||||||
const [sizeLiters, setSizeLiters] = useState("");
|
const [sizeLiters, setSizeLiters] = useState("");
|
||||||
|
const [unit, setUnit] = useState<ProductUnit>("L");
|
||||||
const [unitPrice, setUnitPrice] = useState("");
|
const [unitPrice, setUnitPrice] = useState("");
|
||||||
|
const [showInactive, setShowInactive] = useState(false);
|
||||||
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editPrice, setEditPrice] = useState("");
|
const [editPrice, setEditPrice] = useState("");
|
||||||
@@ -50,7 +52,7 @@ export default function ProductsPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function loadProducts() {
|
async function loadProducts() {
|
||||||
const result = await apiFetch<{ products: Product[] }>("/api/products");
|
const result = await apiFetch<{ products: Product[] }>("/api/products?includeInactive=1");
|
||||||
setProducts(result.products);
|
setProducts(result.products);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,8 +62,8 @@ export default function ProductsPage() {
|
|||||||
|
|
||||||
const size = parseFloat(sizeLiters);
|
const size = parseFloat(sizeLiters);
|
||||||
const price = parseFloat(unitPrice);
|
const price = parseFloat(unitPrice);
|
||||||
if (!label.trim() || !Number.isFinite(size) || size <= 0 || !Number.isFinite(price) || price < 0) {
|
if (!label.trim() || !Number.isFinite(size) || size <= 0 || !Number.isFinite(price) || price <= 0) {
|
||||||
setError("Enter a valid label, size, and price.");
|
setError("Enter a label, a size above zero and a price above zero.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +71,7 @@ export default function ProductsPage() {
|
|||||||
try {
|
try {
|
||||||
await apiFetch("/api/products", {
|
await apiFetch("/api/products", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ label: label.trim(), sizeLiters: size, unitPrice: price, sortOrder: products.length }),
|
body: JSON.stringify({ label: label.trim(), sizeLiters: size, unit, unitPrice: price, sortOrder: products.length }),
|
||||||
});
|
});
|
||||||
show(label.trim() + " added to the catalog", "success");
|
show(label.trim() + " added to the catalog", "success");
|
||||||
setLabel("");
|
setLabel("");
|
||||||
@@ -87,8 +89,8 @@ export default function ProductsPage() {
|
|||||||
async function handlePriceUpdate(id: string) {
|
async function handlePriceUpdate(id: string) {
|
||||||
const price = parseFloat(editPrice);
|
const price = parseFloat(editPrice);
|
||||||
const threshold = parseInt(editThreshold, 10);
|
const threshold = parseInt(editThreshold, 10);
|
||||||
if (!Number.isFinite(price) || price < 0) {
|
if (!Number.isFinite(price) || price <= 0) {
|
||||||
setError("Enter a valid price.");
|
setError("Enter a price above zero.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!Number.isInteger(threshold) || threshold < 0) {
|
if (!Number.isInteger(threshold) || threshold < 0) {
|
||||||
@@ -113,6 +115,24 @@ export default function ProductsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleToggleActive(product: Product) {
|
async function handleToggleActive(product: Product) {
|
||||||
|
if (product.isActive) {
|
||||||
|
// Tell the owner what happens to stock still on hand before it goes.
|
||||||
|
let held: StockLevel[] = [];
|
||||||
|
try {
|
||||||
|
const res = await apiFetch<{ levels: StockLevel[] }>("/api/stock");
|
||||||
|
held = res.levels.filter((l) => l.productId === product._id && l.quantity !== 0);
|
||||||
|
} catch {
|
||||||
|
// Offline or failed: still ask for confirmation below.
|
||||||
|
}
|
||||||
|
const stockNote =
|
||||||
|
held.length > 0
|
||||||
|
? `\n\nStill in stock: ${held.map((l) => `${l.branchName} ${l.quantity}`).join(", ")}. ` +
|
||||||
|
"It stays on the Stock page, where it can be transferred or counted out, but cashiers can no longer sell it."
|
||||||
|
: "";
|
||||||
|
if (!window.confirm(`Deactivate ${product.label}? It disappears from the till and new purchases. Past sales and purchases are kept.${stockNote}`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await apiFetch("/api/products/" + product._id, {
|
await apiFetch("/api/products/" + product._id, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
@@ -145,7 +165,7 @@ export default function ProductsPage() {
|
|||||||
<div className="mx-auto flex max-w-3xl items-center justify-between">
|
<div className="mx-auto flex max-w-3xl items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-wide text-flow-400">Vessel</p>
|
<p className="text-xs uppercase tracking-wide text-flow-400">Vessel</p>
|
||||||
<h1 className="font-display text-xl font-bold">Jerrycan sizes & pricing</h1>
|
<h1 className="font-display text-xl font-bold">Products & pricing</h1>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/admin/dashboard" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
|
<Link href="/admin/dashboard" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
|
||||||
← Dashboard
|
← Dashboard
|
||||||
@@ -166,6 +186,17 @@ export default function ProductsPage() {
|
|||||||
it from new sales without touching past transactions.
|
it from new sales without touching past transactions.
|
||||||
{!canEdit && " Only the business owner can change products and prices."}
|
{!canEdit && " Only the business owner can change products and prices."}
|
||||||
</p>
|
</p>
|
||||||
|
{products.some((p) => !p.isActive) && (
|
||||||
|
<label className="mt-2 flex cursor-pointer items-center gap-2 text-xs text-depth-600">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showInactive}
|
||||||
|
onChange={(e) => setShowInactive(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-depth-300"
|
||||||
|
/>
|
||||||
|
Show deactivated products ({products.filter((p) => !p.isActive).length})
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{products.length === 0 ? (
|
{products.length === 0 ? (
|
||||||
@@ -177,11 +208,11 @@ export default function ProductsPage() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-depth-100">
|
<ul className="divide-y divide-depth-100">
|
||||||
{products.map((p) => (
|
{products.filter((p) => showInactive || p.isActive).map((p) => (
|
||||||
<li key={p._id} className="flex items-center justify-between gap-3 px-4 py-3 animate-fade-up">
|
<li key={p._id} className="flex items-center justify-between gap-3 px-4 py-3 animate-fade-up">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-depth-900">
|
<p className="font-medium text-depth-900">
|
||||||
{p.label} <span className="text-depth-500">· {p.sizeLiters}L</span>
|
{p.label} <span className="text-depth-500">· {formatSize(p.sizeLiters, p.unit)}</span>
|
||||||
</p>
|
</p>
|
||||||
{!canEdit ? (
|
{!canEdit ? (
|
||||||
<p className="mt-0.5 font-mono text-sm text-depth-600">
|
<p className="mt-0.5 font-mono text-sm text-depth-600">
|
||||||
@@ -235,13 +266,12 @@ export default function ProductsPage() {
|
|||||||
{p.isActive ? "Active" : "Inactive"}
|
{p.isActive ? "Active" : "Inactive"}
|
||||||
</span>
|
</span>
|
||||||
{!canEdit ? null : p.isActive ? (
|
{!canEdit ? null : p.isActive ? (
|
||||||
<ConfirmButton
|
<button
|
||||||
label="Deactivate"
|
onClick={() => handleToggleActive(p)}
|
||||||
confirmLabel="Deactivate"
|
className="rounded-lg border border-alert-500/40 px-3 py-1.5 text-sm font-medium text-alert-600"
|
||||||
variant="danger"
|
>
|
||||||
onConfirm={() => handleToggleActive(p)}
|
Deactivate
|
||||||
className="rounded-lg border border-depth-200 px-3 py-1.5 text-sm font-medium text-depth-700"
|
</button>
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleToggleActive(p)}
|
onClick={() => handleToggleActive(p)}
|
||||||
@@ -259,8 +289,8 @@ export default function ProductsPage() {
|
|||||||
|
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<form onSubmit={handleAdd} className="rounded-xl2 bg-white p-4 shadow-soft">
|
<form onSubmit={handleAdd} className="rounded-xl2 bg-white p-4 shadow-soft">
|
||||||
<h2 className="mb-3 font-display font-semibold text-depth-900">Add a new size</h2>
|
<h2 className="mb-3 font-display font-semibold text-depth-900">Add a product</h2>
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="label">
|
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="label">
|
||||||
Label
|
Label
|
||||||
@@ -269,19 +299,31 @@ export default function ProductsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="size">
|
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="size">
|
||||||
Size (litres)
|
Size
|
||||||
</label>
|
</label>
|
||||||
<input id="size" type="number" min="0" step="0.1" value={sizeLiters} onChange={(e) => setSizeLiters(e.target.value)} placeholder="10" className="tap-target w-full rounded-xl border border-depth-200 px-3" />
|
<input id="size" type="number" min="0" step="0.1" value={sizeLiters} onChange={(e) => setSizeLiters(e.target.value)} placeholder="10" className="tap-target w-full rounded-xl border border-depth-200 px-3" />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="unit">
|
||||||
|
Unit
|
||||||
|
</label>
|
||||||
|
<select id="unit" value={unit} onChange={(e) => setUnit(e.target.value as ProductUnit)} className="tap-target w-full rounded-xl border border-depth-200 px-3">
|
||||||
|
{PRODUCT_UNITS.map((u) => (
|
||||||
|
<option key={u} value={u}>
|
||||||
|
{UNIT_LABELS[u]} ({u})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="price">
|
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="price">
|
||||||
Price (KES)
|
Price (KES)
|
||||||
</label>
|
</label>
|
||||||
<input id="price" type="number" min="0" step="0.01" value={unitPrice} onChange={(e) => setUnitPrice(e.target.value)} placeholder="110" className="tap-target w-full rounded-xl border border-depth-200 px-3" />
|
<input id="price" type="number" min="0.01" step="0.01" value={unitPrice} onChange={(e) => setUnitPrice(e.target.value)} placeholder="110" className="tap-target w-full rounded-xl border border-depth-200 px-3" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" disabled={saving} className="tap-target mt-4 w-full rounded-xl2 bg-flow-500 font-display font-semibold text-depth-950 disabled:opacity-60 sm:w-auto sm:px-8">
|
<button type="submit" disabled={saving} className="tap-target mt-4 w-full rounded-xl2 bg-flow-500 font-display font-semibold text-depth-950 disabled:opacity-60 sm:w-auto sm:px-8">
|
||||||
{saving ? "Adding…" : "Add size"}
|
{saving ? "Adding…" : "Add product"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -15,16 +15,22 @@ interface ProfitReport {
|
|||||||
cashTotal: number;
|
cashTotal: number;
|
||||||
mpesaTotal: number;
|
mpesaTotal: number;
|
||||||
salesCount: number;
|
salesCount: number;
|
||||||
costOfGoods: number;
|
unitsSold: number;
|
||||||
purchaseCount: number;
|
costOfGoodsSold: number;
|
||||||
|
unitsWithoutCost: number;
|
||||||
grossProfit: number;
|
grossProfit: number;
|
||||||
expenses: number;
|
expenses: number;
|
||||||
expenseCount: number;
|
expenseCount: number;
|
||||||
netProfit: number;
|
netProfit: number;
|
||||||
|
stockPurchases: number;
|
||||||
|
purchaseCount: number;
|
||||||
|
cashSurplus: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** YYYY-MM-DD in the device's own calendar (the owner's business day). */
|
||||||
function isoDay(d: Date): string {
|
function isoDay(d: Date): string {
|
||||||
return d.toISOString().slice(0, 10);
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ReportsPage() {
|
export default function ReportsPage() {
|
||||||
@@ -35,6 +41,10 @@ export default function ReportsPage() {
|
|||||||
const [report, setReport] = useState<ProfitReport | null>(null);
|
const [report, setReport] = useState<ProfitReport | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [fetching, setFetching] = useState(false);
|
const [fetching, setFetching] = useState(false);
|
||||||
|
const [rangeError, setRangeError] = useState<string | null>(null);
|
||||||
|
// The period the cards currently show, so a failed or invalid run can
|
||||||
|
// never be mistaken for the new period.
|
||||||
|
const [shown, setShown] = useState<{ from: string; to: string } | null>(null);
|
||||||
|
|
||||||
const monthStart = new Date();
|
const monthStart = new Date();
|
||||||
monthStart.setDate(1);
|
monthStart.setDate(1);
|
||||||
@@ -75,22 +85,27 @@ export default function ReportsPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function runReport(forcedBranch?: string) {
|
async function runReport(forcedBranch?: string) {
|
||||||
|
if (!from || !to) {
|
||||||
|
setRangeError("Choose both a start and an end date.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (from > to) {
|
||||||
|
setRangeError("The start date must be on or before the end date.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRangeError(null);
|
||||||
setFetching(true);
|
setFetching(true);
|
||||||
try {
|
try {
|
||||||
const b = forcedBranch ?? branchId;
|
const b = forcedBranch ?? branchId;
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams({ fromDate: from, toDate: to });
|
||||||
if (b && b !== "ALL") params.set("branchId", b);
|
if (b && b !== "ALL") params.set("branchId", b);
|
||||||
if (from) params.set("from", new Date(from).toISOString());
|
|
||||||
// include the whole "to" day
|
|
||||||
if (to) {
|
|
||||||
const end = new Date(to);
|
|
||||||
end.setHours(23, 59, 59, 999);
|
|
||||||
params.set("to", end.toISOString());
|
|
||||||
}
|
|
||||||
const result = await apiFetch<ProfitReport>("/api/reports/profit?" + params.toString());
|
const result = await apiFetch<ProfitReport>("/api/reports/profit?" + params.toString());
|
||||||
setReport(result);
|
setReport(result);
|
||||||
|
setShown({ from, to });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
show(err instanceof ApiError ? err.message : "Could not load report", "error");
|
const message = err instanceof ApiError ? err.message : "Could not load report";
|
||||||
|
setRangeError(message);
|
||||||
|
show(message, "error");
|
||||||
}
|
}
|
||||||
setFetching(false);
|
setFetching(false);
|
||||||
}
|
}
|
||||||
@@ -99,23 +114,27 @@ export default function ReportsPage() {
|
|||||||
if (!report) return;
|
if (!report) return;
|
||||||
const rows: [string, string | number][] = [
|
const rows: [string, string | number][] = [
|
||||||
["Metric", "Value (KES)"],
|
["Metric", "Value (KES)"],
|
||||||
["Range from", report.range.from.slice(0, 10)],
|
["From", shown?.from ?? ""],
|
||||||
["Range to", report.range.to.slice(0, 10)],
|
["To", shown?.to ?? ""],
|
||||||
["Revenue", report.revenue],
|
["Revenue", report.revenue],
|
||||||
[" Cash", report.cashTotal],
|
[" Cash", report.cashTotal],
|
||||||
[" M-Pesa", report.mpesaTotal],
|
[" M-Pesa", report.mpesaTotal],
|
||||||
["Sales count", report.salesCount],
|
["Sales count", report.salesCount],
|
||||||
["Cost of goods", report.costOfGoods],
|
["Units sold", report.unitsSold],
|
||||||
|
["Cost of goods sold (average purchase cost)", report.costOfGoodsSold],
|
||||||
|
["Units sold with no purchase cost on record", report.unitsWithoutCost],
|
||||||
["Gross profit", report.grossProfit],
|
["Gross profit", report.grossProfit],
|
||||||
["Expenses", report.expenses],
|
["Expenses", report.expenses],
|
||||||
["Estimated net profit", report.netProfit],
|
["Estimated net profit", report.netProfit],
|
||||||
|
["Stock purchased in period (cash spent)", report.stockPurchases],
|
||||||
|
["Cash surplus (sales - stock purchased - expenses)", report.cashSurplus],
|
||||||
];
|
];
|
||||||
const csv = rows.map((r) => r.map((c) => `"${String(c).replace(/"/g, '""')}"`).join(",")).join("\n");
|
const csv = rows.map((r) => r.map((c) => `"${String(c).replace(/"/g, '""')}"`).join(",")).join("\n");
|
||||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `vessel-profit-${report.range.from.slice(0, 10)}_${report.range.to.slice(0, 10)}.csv`;
|
a.download = `vessel-profit-${shown?.from}_${shown?.to}.csv`;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
@@ -179,6 +198,7 @@ export default function ReportsPage() {
|
|||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={from}
|
value={from}
|
||||||
|
max={to || undefined}
|
||||||
onChange={(e) => setFrom(e.target.value)}
|
onChange={(e) => setFrom(e.target.value)}
|
||||||
className="tap-target w-full rounded-xl border border-depth-200 px-3"
|
className="tap-target w-full rounded-xl border border-depth-200 px-3"
|
||||||
/>
|
/>
|
||||||
@@ -188,6 +208,7 @@ export default function ReportsPage() {
|
|||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={to}
|
value={to}
|
||||||
|
min={from || undefined}
|
||||||
onChange={(e) => setTo(e.target.value)}
|
onChange={(e) => setTo(e.target.value)}
|
||||||
className="tap-target w-full rounded-xl border border-depth-200 px-3"
|
className="tap-target w-full rounded-xl border border-depth-200 px-3"
|
||||||
/>
|
/>
|
||||||
@@ -202,8 +223,16 @@ export default function ReportsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{rangeError && <p className="mt-3 text-sm text-alert-600">{rangeError}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{report && shown && (
|
||||||
|
<p className="text-sm text-depth-600">
|
||||||
|
Showing {shown.from === shown.to ? shown.from : `${shown.from} to ${shown.to}`}
|
||||||
|
{rangeError ? " (the dates above were not run)" : ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{report && (
|
{report && (
|
||||||
<>
|
<>
|
||||||
{/* Profit cards */}
|
{/* Profit cards */}
|
||||||
@@ -216,14 +245,17 @@ export default function ReportsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl2 bg-white p-5 shadow-soft">
|
<div className="rounded-xl2 bg-white p-5 shadow-soft">
|
||||||
<p className="text-xs uppercase tracking-wide text-depth-500">Cost of goods</p>
|
<p className="text-xs uppercase tracking-wide text-depth-500">Cost of goods sold</p>
|
||||||
<p className="mt-1 font-display text-2xl font-bold text-depth-900">{money(report.costOfGoods)}</p>
|
<p className="mt-1 font-display text-2xl font-bold text-depth-900">{money(report.costOfGoodsSold)}</p>
|
||||||
<p className="mt-1 text-xs text-depth-500">{report.purchaseCount} purchases in range</p>
|
<p className="mt-1 text-xs text-depth-500">
|
||||||
|
{report.unitsSold} units sold x average buying price
|
||||||
|
{report.unitsWithoutCost > 0 && ` · ${report.unitsWithoutCost} units have no purchase cost on record`}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl2 bg-white p-5 shadow-soft">
|
<div className="rounded-xl2 bg-white p-5 shadow-soft">
|
||||||
<p className="text-xs uppercase tracking-wide text-depth-500">Gross profit</p>
|
<p className="text-xs uppercase tracking-wide text-depth-500">Gross profit</p>
|
||||||
<p className="mt-1 font-display text-2xl font-bold text-flow-700">{money(report.grossProfit)}</p>
|
<p className="mt-1 font-display text-2xl font-bold text-flow-700">{money(report.grossProfit)}</p>
|
||||||
<p className="mt-1 text-xs text-depth-500">Revenue − cost of goods</p>
|
<p className="mt-1 text-xs text-depth-500">Revenue − cost of goods sold</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl2 bg-white p-5 shadow-soft">
|
<div className="rounded-xl2 bg-white p-5 shadow-soft">
|
||||||
<p className="text-xs uppercase tracking-wide text-depth-500">Expenses</p>
|
<p className="text-xs uppercase tracking-wide text-depth-500">Expenses</p>
|
||||||
@@ -244,6 +276,22 @@ export default function ReportsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<div className="rounded-xl2 bg-white p-5 shadow-soft">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-depth-500">Stock purchased</p>
|
||||||
|
<p className="mt-1 font-display text-2xl font-bold text-depth-900">{money(report.stockPurchases)}</p>
|
||||||
|
<p className="mt-1 text-xs text-depth-500">
|
||||||
|
Cash spent on stock in this period ({report.purchaseCount} purchases). Unsold stock is not a cost
|
||||||
|
until it is sold.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl2 bg-white p-5 shadow-soft">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-depth-500">Cash surplus</p>
|
||||||
|
<p className="mt-1 font-display text-2xl font-bold text-depth-900">{money(report.cashSurplus)}</p>
|
||||||
|
<p className="mt-1 text-xs text-depth-500">Sales − stock purchased − expenses</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between rounded-xl2 border border-depth-200 bg-sand-100 px-4 py-3">
|
<div className="flex items-center justify-between rounded-xl2 border border-depth-200 bg-sand-100 px-4 py-3">
|
||||||
<p className="text-xs text-depth-500">
|
<p className="text-xs text-depth-500">
|
||||||
Management / profit overview — a decision aid, not full accounting software.
|
Management / profit overview — a decision aid, not full accounting software.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface StaffMember {
|
|||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
email: string;
|
email: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
invitePending?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleBadgeStyles: Record<UserRole, string> = {
|
const roleBadgeStyles: Record<UserRole, string> = {
|
||||||
@@ -42,6 +43,10 @@ export default function StaffPage() {
|
|||||||
const [phoneNumber, setPhoneNumber] = useState("");
|
const [phoneNumber, setPhoneNumber] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
// Inviting is the default: the person sets their own password, so the
|
||||||
|
// owner never has to know or pass on anyone's password.
|
||||||
|
const [setPasswordNow, setSetPasswordNow] = useState(false);
|
||||||
|
const [inviteLink, setInviteLink] = useState<{ name: string; url: string } | null>(null);
|
||||||
|
|
||||||
const isSuperAdmin = me?.role === "SUPER_ADMIN";
|
const isSuperAdmin = me?.role === "SUPER_ADMIN";
|
||||||
|
|
||||||
@@ -75,14 +80,14 @@ export default function StaffPage() {
|
|||||||
if (!name.trim() || !phoneNumber.trim()) return "Name and phone number are required.";
|
if (!name.trim() || !phoneNumber.trim()) return "Name and phone number are required.";
|
||||||
if (role !== "SUPER_ADMIN" && isSuperAdmin && !branchId) return "Choose which branch this account belongs to.";
|
if (role !== "SUPER_ADMIN" && isSuperAdmin && !branchId) return "Choose which branch this account belongs to.";
|
||||||
if (!email.trim()) return "Email is required — every account signs in with email and password.";
|
if (!email.trim()) return "Email is required — every account signs in with email and password.";
|
||||||
if (password.length < 8) return "Password must be at least 8 characters.";
|
if (setPasswordNow && password.length < 8) return "Password must be at least 8 characters.";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitCreate() {
|
async function submitCreate() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await apiFetch("/api/users", {
|
const result = await apiFetch<{ inviteUrl: string | null }>("/api/users", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
@@ -90,10 +95,11 @@ export default function StaffPage() {
|
|||||||
branchId: role !== "SUPER_ADMIN" && isSuperAdmin ? branchId : undefined,
|
branchId: role !== "SUPER_ADMIN" && isSuperAdmin ? branchId : undefined,
|
||||||
phoneNumber,
|
phoneNumber,
|
||||||
email,
|
email,
|
||||||
password,
|
password: setPasswordNow ? password : undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
show(name.trim() + " added as " + role.replace("_", " ").toLowerCase(), "success");
|
show(name.trim() + " added as " + role.replace("_", " ").toLowerCase(), "success");
|
||||||
|
setInviteLink(result.inviteUrl ? { name: name.trim(), url: result.inviteUrl } : null);
|
||||||
setName("");
|
setName("");
|
||||||
setBranchId("");
|
setBranchId("");
|
||||||
setPhoneNumber("");
|
setPhoneNumber("");
|
||||||
@@ -115,6 +121,26 @@ export default function StaffPage() {
|
|||||||
return s.role === "DELIVERY" && s.branchId === me.branchId;
|
return s.role === "DELIVERY" && s.branchId === me.branchId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function newInviteLink(s: StaffMember) {
|
||||||
|
try {
|
||||||
|
const result = await apiFetch<{ inviteUrl: string }>("/api/users/" + s._id + "/invite", { method: "POST" });
|
||||||
|
setInviteLink({ name: s.name, url: result.inviteUrl });
|
||||||
|
show("New invite link created. The old link no longer works.", "success");
|
||||||
|
} catch (err) {
|
||||||
|
show(err instanceof ApiError ? err.message : "Could not create an invite link", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyInvite() {
|
||||||
|
if (!inviteLink) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(inviteLink.url);
|
||||||
|
show("Invite link copied", "success");
|
||||||
|
} catch {
|
||||||
|
show("Could not copy - select the link and copy it manually", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleActive(s: StaffMember) {
|
async function toggleActive(s: StaffMember) {
|
||||||
const next = !s.isActive;
|
const next = !s.isActive;
|
||||||
if (!next && !window.confirm(`Deactivate ${s.name}? They will be signed out of every device and cannot sign in again until reactivated.`)) {
|
if (!next && !window.confirm(`Deactivate ${s.name}? They will be signed out of every device and cannot sign in again until reactivated.`)) {
|
||||||
@@ -191,6 +217,36 @@ export default function StaffPage() {
|
|||||||
<div className="rounded-xl2 border border-alert-500/30 bg-alert-500/10 px-4 py-3 text-sm text-alert-600">{error}</div>
|
<div className="rounded-xl2 border border-alert-500/30 bg-alert-500/10 px-4 py-3 text-sm text-alert-600">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{inviteLink && (
|
||||||
|
<div className="rounded-xl2 border border-confirm-500/30 bg-confirm-500/10 p-4 text-sm text-depth-800">
|
||||||
|
<p className="font-medium">Send this link to {inviteLink.name} so they can set their password.</p>
|
||||||
|
<p className="mt-1 text-xs text-depth-500">It works once and expires in 7 days. Anyone with the link can claim the account, so send it only to them.</p>
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
value={inviteLink.url}
|
||||||
|
onFocus={(e) => e.target.select()}
|
||||||
|
aria-label="Invite link"
|
||||||
|
className="mt-3 w-full rounded-lg border border-depth-200 bg-white px-3 py-2 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
<button onClick={copyInvite} className="rounded-lg bg-flow-500 px-3 py-1.5 text-xs font-semibold text-depth-950">
|
||||||
|
Copy link
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href={`https://wa.me/?text=${encodeURIComponent(`Set your Vessel password here: ${inviteLink.url}`)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="rounded-lg border border-depth-200 bg-white px-3 py-1.5 text-xs font-semibold text-depth-700"
|
||||||
|
>
|
||||||
|
Share on WhatsApp
|
||||||
|
</a>
|
||||||
|
<button onClick={() => setInviteLink(null)} className="px-2 text-xs text-depth-500 underline">
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="rounded-xl2 bg-white shadow-soft">
|
<div className="rounded-xl2 bg-white shadow-soft">
|
||||||
<div className="border-b border-depth-100 px-4 py-3">
|
<div className="border-b border-depth-100 px-4 py-3">
|
||||||
<h2 className="font-display font-semibold text-depth-900">Team</h2>
|
<h2 className="font-display font-semibold text-depth-900">Team</h2>
|
||||||
@@ -211,6 +267,9 @@ export default function StaffPage() {
|
|||||||
<p className="font-medium text-depth-900">
|
<p className="font-medium text-depth-900">
|
||||||
{s.name}
|
{s.name}
|
||||||
{!s.isActive && <span className="ml-2 text-xs font-normal text-alert-600">Deactivated</span>}
|
{!s.isActive && <span className="ml-2 text-xs font-normal text-alert-600">Deactivated</span>}
|
||||||
|
{s.isActive && s.invitePending && (
|
||||||
|
<span className="ml-2 text-xs font-normal text-cash-700">Invite pending</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-depth-500">
|
<p className="text-xs text-depth-500">
|
||||||
{s.email}
|
{s.email}
|
||||||
@@ -221,6 +280,14 @@ export default function StaffPage() {
|
|||||||
<span className={"rounded-full px-2.5 py-1 text-xs font-medium " + roleBadgeStyles[s.role]}>
|
<span className={"rounded-full px-2.5 py-1 text-xs font-medium " + roleBadgeStyles[s.role]}>
|
||||||
{s.role.replace("_", " ")}
|
{s.role.replace("_", " ")}
|
||||||
</span>
|
</span>
|
||||||
|
{canManage(s) && s.isActive && s.invitePending && (
|
||||||
|
<button
|
||||||
|
onClick={() => newInviteLink(s)}
|
||||||
|
className="rounded-lg border border-depth-200 px-2.5 py-1 text-xs font-medium text-depth-700"
|
||||||
|
>
|
||||||
|
New link
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{canManage(s) && (
|
{canManage(s) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleActive(s)}
|
onClick={() => toggleActive(s)}
|
||||||
@@ -289,12 +356,29 @@ export default function StaffPage() {
|
|||||||
<label className="mb-1 block text-xs font-medium text-depth-600">Email (used to sign in)</label>
|
<label className="mb-1 block text-xs font-medium text-depth-600">Email (used to sign in)</label>
|
||||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="tap-target w-full rounded-xl border border-depth-200 px-3" />
|
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="tap-target w-full rounded-xl border border-depth-200 px-3" />
|
||||||
</div>
|
</div>
|
||||||
|
{setPasswordNow && (
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-depth-600">Password (used to sign in)</label>
|
<label className="mb-1 block text-xs font-medium text-depth-600">Password (used to sign in)</label>
|
||||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="tap-target w-full rounded-xl border border-depth-200 px-3" />
|
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="tap-target w-full rounded-xl border border-depth-200 px-3" />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label className="mt-3 flex cursor-pointer items-start gap-2 text-sm text-depth-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={setPasswordNow}
|
||||||
|
onChange={(e) => setSetPasswordNow(e.target.checked)}
|
||||||
|
className="mt-0.5 h-4 w-4 rounded border-depth-300"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Set their password myself instead of sending an invite link
|
||||||
|
<span className="block text-xs text-depth-500">
|
||||||
|
By default you get a one-time link to send them, and they choose their own password.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
{confirmSuperAdmin ? (
|
{confirmSuperAdmin ? (
|
||||||
<div className="mt-4 rounded-xl2 border border-alert-500/30 bg-alert-500/10 p-3">
|
<div className="mt-4 rounded-xl2 border border-alert-500/30 bg-alert-500/10 p-3">
|
||||||
<p className="mb-2 text-sm text-alert-700">
|
<p className="mb-2 text-sm text-alert-700">
|
||||||
@@ -324,7 +408,7 @@ export default function StaffPage() {
|
|||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="tap-target mt-4 w-full rounded-xl2 bg-flow-500 font-display font-semibold text-depth-950 disabled:opacity-60 sm:w-auto sm:px-8"
|
className="tap-target mt-4 w-full rounded-xl2 bg-flow-500 font-display font-semibold text-depth-950 disabled:opacity-60 sm:w-auto sm:px-8"
|
||||||
>
|
>
|
||||||
{saving ? "Adding…" : "Add staff member"}
|
{saving ? "Adding…" : setPasswordNow ? "Add staff member" : "Add and create invite link"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -73,7 +73,9 @@ export default function StockPage() {
|
|||||||
}
|
}
|
||||||
const [br, pr] = await Promise.all([
|
const [br, pr] = await Promise.all([
|
||||||
apiFetch<{ branches: Branch[] }>("/api/branches"),
|
apiFetch<{ branches: Branch[] }>("/api/branches"),
|
||||||
apiFetch<{ products: Product[] }>("/api/products"),
|
// Deactivated products too: their remaining stock can still be
|
||||||
|
// transferred or counted out.
|
||||||
|
apiFetch<{ products: Product[] }>("/api/products?includeInactive=1"),
|
||||||
]);
|
]);
|
||||||
setBranches(br.branches);
|
setBranches(br.branches);
|
||||||
setProducts(pr.products);
|
setProducts(pr.products);
|
||||||
@@ -252,7 +254,10 @@ export default function StockPage() {
|
|||||||
<ul className="divide-y divide-depth-100">
|
<ul className="divide-y divide-depth-100">
|
||||||
{group.rows.map((l) => (
|
{group.rows.map((l) => (
|
||||||
<li key={l.productId} className="flex items-center justify-between gap-3 px-4 py-2.5">
|
<li key={l.productId} className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||||||
<span className="min-w-0 truncate text-sm text-depth-800">{l.productLabel}</span>
|
<span className="min-w-0 truncate text-sm text-depth-800">
|
||||||
|
{l.productLabel}
|
||||||
|
{!l.productActive && <span className="ml-1 text-xs text-depth-500">(deactivated)</span>}
|
||||||
|
</span>
|
||||||
<span className="flex shrink-0 items-center gap-2">
|
<span className="flex shrink-0 items-center gap-2">
|
||||||
<span className={"font-mono text-sm font-semibold " + (l.quantity < 0 ? "text-alert-600" : "text-depth-900")}>
|
<span className={"font-mono text-sm font-semibold " + (l.quantity < 0 ? "text-alert-600" : "text-depth-900")}>
|
||||||
{l.quantity}
|
{l.quantity}
|
||||||
@@ -347,6 +352,7 @@ export default function StockPage() {
|
|||||||
{products.map((p) => (
|
{products.map((p) => (
|
||||||
<option key={p._id} value={p._id}>
|
<option key={p._id} value={p._id}>
|
||||||
{p.label}
|
{p.label}
|
||||||
|
{p.isActive ? "" : " (deactivated)"}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Business } from "@/lib/server/models/Business";
|
|||||||
import { getAuth } from "@/lib/server/auth";
|
import { getAuth } from "@/lib/server/auth";
|
||||||
import { isPlatformAdminEmail } from "@/lib/server/platform";
|
import { isPlatformAdminEmail } from "@/lib/server/platform";
|
||||||
import { isMpesaEnabled } from "@/lib/server/env";
|
import { isMpesaEnabled } from "@/lib/server/env";
|
||||||
import { TIERS, type TierId, type SubscriptionStatus } from "@/lib/tiers";
|
import { TIERS, effectiveMaxBranches, type TierId, type SubscriptionStatus } from "@/lib/tiers";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -47,7 +47,7 @@ export async function GET(req: NextRequest) {
|
|||||||
status,
|
status,
|
||||||
trialEndsAt: business?.trialEndsAt ?? null,
|
trialEndsAt: business?.trialEndsAt ?? null,
|
||||||
currentPeriodEnd: business?.currentPeriodEnd ?? null,
|
currentPeriodEnd: business?.currentPeriodEnd ?? null,
|
||||||
maxBranches: tierDef.maxBranches,
|
maxBranches: effectiveMaxBranches(tier, status),
|
||||||
maxUsers: tierDef.maxUsers,
|
maxUsers: tierDef.maxUsers,
|
||||||
},
|
},
|
||||||
features: { mpesa: isMpesaEnabled() },
|
features: { mpesa: isMpesaEnabled() },
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const DEFAULT_CATALOG = [
|
|||||||
{ label: "5L Jerrycan", sizeLiters: 5, unit: "L", category: "Water", unitPrice: 60, sortOrder: 1 },
|
{ label: "5L Jerrycan", sizeLiters: 5, unit: "L", category: "Water", unitPrice: 60, sortOrder: 1 },
|
||||||
{ label: "10L Jerrycan", sizeLiters: 10, unit: "L", category: "Water", unitPrice: 110, sortOrder: 2 },
|
{ label: "10L Jerrycan", sizeLiters: 10, unit: "L", category: "Water", unitPrice: 110, sortOrder: 2 },
|
||||||
{ label: "20L Jerrycan", sizeLiters: 20, unit: "L", category: "Water", unitPrice: 200, sortOrder: 3 },
|
{ label: "20L Jerrycan", sizeLiters: 20, unit: "L", category: "Water", unitPrice: 200, sortOrder: 3 },
|
||||||
{ label: "Tray of Eggs (30)", sizeLiters: 30, unit: "pcs", category: "Eggs", unitPrice: 400, sortOrder: 4 },
|
{ label: "Tray of Eggs (30)", sizeLiters: 1, unit: "tray", category: "Eggs", unitPrice: 400, sortOrder: 4 },
|
||||||
{ label: "Half Tray (15)", sizeLiters: 15, unit: "pcs", category: "Eggs", unitPrice: 210, sortOrder: 5 },
|
{ label: "Half Tray (15)", sizeLiters: 15, unit: "pcs", category: "Eggs", unitPrice: 210, sortOrder: 5 },
|
||||||
{ label: "Single Egg", sizeLiters: 1, unit: "pcs", category: "Eggs", unitPrice: 15, sortOrder: 6 },
|
{ label: "Single Egg", sizeLiters: 1, unit: "pcs", category: "Eggs", unitPrice: 15, sortOrder: 6 },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { z } from "zod";
|
|||||||
import { isValidObjectId } from "mongoose";
|
import { isValidObjectId } from "mongoose";
|
||||||
import { connectDb } from "@/lib/server/db";
|
import { connectDb } from "@/lib/server/db";
|
||||||
import { Branch } from "@/lib/server/models/Branch";
|
import { Branch } from "@/lib/server/models/Branch";
|
||||||
|
import { User } from "@/lib/server/models/User";
|
||||||
import { authorize } from "@/lib/server/auth";
|
import { authorize } from "@/lib/server/auth";
|
||||||
|
import { branchLimitError } from "@/lib/server/subscription";
|
||||||
|
import { getStockLevels } from "@/lib/server/stock";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
@@ -11,10 +14,21 @@ const updateSchema = z
|
|||||||
.object({
|
.object({
|
||||||
branchName: z.string().trim().min(1).max(120).optional(),
|
branchName: z.string().trim().min(1).max(120).optional(),
|
||||||
locationCity: z.string().trim().min(1).max(120).optional(),
|
locationCity: z.string().trim().min(1).max(120).optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.refine((v) => Object.keys(v).length > 0, { message: "No fields to update" });
|
.refine((v) => Object.keys(v).length > 0, { message: "No fields to update" });
|
||||||
|
|
||||||
// PATCH /api/branches/:id — owner renames a branch or changes its city.
|
/**
|
||||||
|
* PATCH /api/branches/:id — owner renames a branch, or deactivates /
|
||||||
|
* reactivates it. This is how an owner "removes" a branch: nothing is
|
||||||
|
* deleted, so its sales, purchases and stock history stay in every report.
|
||||||
|
*
|
||||||
|
* A branch can only be deactivated once it is empty, so nothing is left
|
||||||
|
* stranded in it:
|
||||||
|
* - no stock on hand (transfer it out or count it to zero first), and
|
||||||
|
* - no active staff (move or deactivate them first).
|
||||||
|
* Reactivating counts against the plan's branch limit again.
|
||||||
|
*/
|
||||||
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
|
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
|
||||||
await connectDb();
|
await connectDb();
|
||||||
const gate = authorize(req, ["SUPER_ADMIN"]);
|
const gate = authorize(req, ["SUPER_ADMIN"]);
|
||||||
@@ -28,10 +42,36 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
const { isActive, ...details } = parsed.data;
|
||||||
|
|
||||||
const branch = await Branch.findOneAndUpdate({ _id: params.id, businessId: auth.businessId }, parsed.data, {
|
const branch = await Branch.findOne({ _id: params.id, businessId: auth.businessId });
|
||||||
new: true,
|
|
||||||
}).lean();
|
|
||||||
if (!branch) return NextResponse.json({ error: "Branch not found" }, { status: 404 });
|
if (!branch) return NextResponse.json({ error: "Branch not found" }, { status: 404 });
|
||||||
|
const wasActive = branch.isActive !== false;
|
||||||
|
|
||||||
|
if (isActive === false && wasActive) {
|
||||||
|
const stock = (await getStockLevels(auth.businessId, branch._id)).filter((l) => l.quantity !== 0);
|
||||||
|
if (stock.length > 0) {
|
||||||
|
const list = stock.map((l) => `${l.quantity} x ${l.productLabel}`).join(", ");
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Move or count out this branch's stock first (${list}).`, stock },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const staff = await User.countDocuments({ businessId: auth.businessId, branchId: branch._id, isActive: true });
|
||||||
|
if (staff > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `${staff} active staff member(s) still work at this branch. Move or deactivate them first.` },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isActive === true && !wasActive) {
|
||||||
|
const limitError = await branchLimitError(auth.businessId);
|
||||||
|
if (limitError) return NextResponse.json({ error: limitError }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(branch, details);
|
||||||
|
if (isActive !== undefined) branch.isActive = isActive;
|
||||||
|
await branch.save();
|
||||||
return NextResponse.json({ branch });
|
return NextResponse.json({ branch });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { connectDb } from "@/lib/server/db";
|
import { connectDb } from "@/lib/server/db";
|
||||||
import { Branch } from "@/lib/server/models/Branch";
|
import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
|
||||||
import { User } from "@/lib/server/models/User";
|
import { User } from "@/lib/server/models/User";
|
||||||
import { authorize, getAuth } from "@/lib/server/auth";
|
import { authorize, getAuth } from "@/lib/server/auth";
|
||||||
import { normalizeKenyanPhone } from "@/lib/server/phone";
|
import { normalizeKenyanPhone } from "@/lib/server/phone";
|
||||||
import { branchLimitError, userLimitError, loadTier } from "@/lib/server/subscription";
|
import { branchLimitError, userLimitError, loadTier } from "@/lib/server/subscription";
|
||||||
|
import { issueInvite, unusablePasswordHash } from "@/lib/server/invites";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -17,7 +18,11 @@ export async function GET(req: NextRequest) {
|
|||||||
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||||
|
|
||||||
if (auth.role === "SUPER_ADMIN") {
|
if (auth.role === "SUPER_ADMIN") {
|
||||||
const branches = await Branch.find({ businessId: auth.businessId }).sort({ branchName: 1 }).lean();
|
// Deactivated branches are only listed on request (the Branches page),
|
||||||
|
// so every picker elsewhere offers active branches only.
|
||||||
|
const includeInactive = req.nextUrl.searchParams.get("includeInactive") === "1";
|
||||||
|
const filter = includeInactive ? { businessId: auth.businessId } : { businessId: auth.businessId, ...ACTIVE_BRANCH };
|
||||||
|
const branches = await Branch.find(filter).sort({ branchName: 1 }).lean();
|
||||||
return NextResponse.json({ branches });
|
return NextResponse.json({ branches });
|
||||||
}
|
}
|
||||||
if (!auth.branchId) return NextResponse.json({ branches: [] });
|
if (!auth.branchId) return NextResponse.json({ branches: [] });
|
||||||
@@ -33,7 +38,8 @@ const createSchema = z.object({
|
|||||||
name: z.string().trim().min(1).max(120),
|
name: z.string().trim().min(1).max(120),
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
phoneNumber: z.string().min(9).max(15),
|
phoneNumber: z.string().min(9).max(15),
|
||||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
// Omit to invite: the response carries a one-time link for the manager.
|
||||||
|
password: z.string().min(8, "Password must be at least 8 characters").optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
@@ -53,7 +59,7 @@ export async function POST(req: NextRequest) {
|
|||||||
const businessId = auth.businessId;
|
const businessId = auth.businessId;
|
||||||
|
|
||||||
const tier = await loadTier(businessId);
|
const tier = await loadTier(businessId);
|
||||||
const branchErr = await branchLimitError(businessId, tier);
|
const branchErr = await branchLimitError(businessId);
|
||||||
if (branchErr) return NextResponse.json({ error: branchErr }, { status: 403 });
|
if (branchErr) return NextResponse.json({ error: branchErr }, { status: 403 });
|
||||||
|
|
||||||
let managerPhone: string | null = null;
|
let managerPhone: string | null = null;
|
||||||
@@ -80,8 +86,8 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
if (manager && managerPhone) {
|
if (manager && managerPhone) {
|
||||||
try {
|
try {
|
||||||
const passwordHash = await bcrypt.hash(manager.password, 12);
|
const passwordHash = manager.password ? await bcrypt.hash(manager.password, 12) : await unusablePasswordHash();
|
||||||
const managerUser = await User.create({
|
const managerUser = new User({
|
||||||
businessId,
|
businessId,
|
||||||
name: manager.name,
|
name: manager.name,
|
||||||
role: "BRANCH_MANAGER",
|
role: "BRANCH_MANAGER",
|
||||||
@@ -90,10 +96,12 @@ export async function POST(req: NextRequest) {
|
|||||||
email: manager.email.toLowerCase(),
|
email: manager.email.toLowerCase(),
|
||||||
passwordHash,
|
passwordHash,
|
||||||
});
|
});
|
||||||
|
const inviteUrl = manager.password ? null : issueInvite(managerUser, req);
|
||||||
|
await managerUser.save();
|
||||||
branch.managerId = managerUser._id;
|
branch.managerId = managerUser._id;
|
||||||
await branch.save();
|
await branch.save();
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ branch, manager: { id: managerUser._id, name: managerUser.name, email: managerUser.email } },
|
{ branch, manager: { id: managerUser._id, name: managerUser.name, email: managerUser.email }, inviteUrl },
|
||||||
{ status: 201 }
|
{ status: 201 }
|
||||||
);
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { connectDb } from "@/lib/server/db";
|
||||||
|
import { User } from "@/lib/server/models/User";
|
||||||
|
import { Business } from "@/lib/server/models/Business";
|
||||||
|
import { hashInviteToken } from "@/lib/server/invites";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
async function findInvitee(token: string) {
|
||||||
|
if (!token || token.length > 100) return null;
|
||||||
|
const user = await User.findOne({ inviteTokenHash: hashInviteToken(token), isActive: true });
|
||||||
|
if (!user || !user.inviteExpiresAt || user.inviteExpiresAt.getTime() < Date.now()) return null;
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INVALID = { error: "This invite link is invalid or has expired. Ask your manager for a new one." };
|
||||||
|
|
||||||
|
// GET /api/invites/:token — who the invite is for, so the page can greet them.
|
||||||
|
export async function GET(_req: NextRequest, { params }: { params: { token: string } }) {
|
||||||
|
await connectDb();
|
||||||
|
const user = await findInvitee(params.token);
|
||||||
|
if (!user) return NextResponse.json(INVALID, { status: 404 });
|
||||||
|
const business = await Business.findById(user.businessId).select("name").lean();
|
||||||
|
return NextResponse.json({ name: user.name, email: user.email, businessName: business?.name ?? null });
|
||||||
|
}
|
||||||
|
|
||||||
|
const acceptSchema = z.object({ password: z.string().min(8, "Password must be at least 8 characters") });
|
||||||
|
|
||||||
|
// POST /api/invites/:token — set the password; the link then stops working.
|
||||||
|
export async function POST(req: NextRequest, { params }: { params: { token: string } }) {
|
||||||
|
await connectDb();
|
||||||
|
const parsed = acceptSchema.safeParse(await req.json().catch(() => null));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const user = await findInvitee(params.token);
|
||||||
|
if (!user) return NextResponse.json(INVALID, { status: 404 });
|
||||||
|
|
||||||
|
user.passwordHash = await bcrypt.hash(parsed.data.password, 12);
|
||||||
|
user.inviteTokenHash = null;
|
||||||
|
user.inviteExpiresAt = null;
|
||||||
|
user.failedLoginAttempts = 0;
|
||||||
|
user.lockedUntil = null;
|
||||||
|
await user.save();
|
||||||
|
return NextResponse.json({ ok: true, email: user.email });
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { Business } from "@/lib/server/models/Business";
|
|||||||
import { User } from "@/lib/server/models/User";
|
import { User } from "@/lib/server/models/User";
|
||||||
import { Branch } from "@/lib/server/models/Branch";
|
import { Branch } from "@/lib/server/models/Branch";
|
||||||
import { requirePlatformAdmin } from "@/lib/server/platform";
|
import { requirePlatformAdmin } from "@/lib/server/platform";
|
||||||
import { TIERS, type TierId } from "@/lib/tiers";
|
import { TIERS, effectiveMaxBranches, type TierId, type SubscriptionStatus } from "@/lib/tiers";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -28,6 +28,7 @@ export async function GET(req: NextRequest) {
|
|||||||
{ $group: { _id: "$businessId", count: { $sum: 1 } } },
|
{ $group: { _id: "$businessId", count: { $sum: 1 } } },
|
||||||
]),
|
]),
|
||||||
Branch.aggregate<{ _id: unknown; count: number }>([
|
Branch.aggregate<{ _id: unknown; count: number }>([
|
||||||
|
{ $match: { isActive: { $ne: false } } },
|
||||||
{ $group: { _id: "$businessId", count: { $sum: 1 } } },
|
{ $group: { _id: "$businessId", count: { $sum: 1 } } },
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
@@ -58,7 +59,7 @@ export async function GET(req: NextRequest) {
|
|||||||
userCount: userMap.get(String(b._id)) ?? 0,
|
userCount: userMap.get(String(b._id)) ?? 0,
|
||||||
branchCount: branchMap.get(String(b._id)) ?? 0,
|
branchCount: branchMap.get(String(b._id)) ?? 0,
|
||||||
maxUsers: TIERS[tier].maxUsers,
|
maxUsers: TIERS[tier].maxUsers,
|
||||||
maxBranches: TIERS[tier].maxBranches,
|
maxBranches: effectiveMaxBranches(tier, (b.subscriptionStatus as SubscriptionStatus) ?? "TRIALING"),
|
||||||
owner: owner
|
owner: owner
|
||||||
? { name: owner.name, email: owner.email, phoneNumber: owner.phoneNumber }
|
? { name: owner.name, email: owner.email, phoneNumber: owner.phoneNumber }
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ import { connectDb } from "@/lib/server/db";
|
|||||||
import { Product } from "@/lib/server/models/Product";
|
import { Product } from "@/lib/server/models/Product";
|
||||||
import { authorize } from "@/lib/server/auth";
|
import { authorize } from "@/lib/server/auth";
|
||||||
import { isValidObjectId } from "mongoose";
|
import { isValidObjectId } from "mongoose";
|
||||||
|
import { PRODUCT_UNITS } from "@/lib/units";
|
||||||
|
import { labelTakenError } from "@/lib/server/products";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
const updateSchema = z.object({
|
const updateSchema = z.object({
|
||||||
label: z.string().trim().min(1).max(60).optional(),
|
label: z.string().trim().min(1).max(60).optional(),
|
||||||
sizeLiters: z.number().positive().max(1000).optional(),
|
sizeLiters: z.number().positive().max(1000).optional(),
|
||||||
unitPrice: z.number().nonnegative().optional(),
|
unit: z.enum(PRODUCT_UNITS).optional(),
|
||||||
|
unitPrice: z.number().positive("Price must be more than zero").optional(),
|
||||||
sortOrder: z.number().int().optional(),
|
sortOrder: z.number().int().optional(),
|
||||||
isActive: z.boolean().optional(),
|
isActive: z.boolean().optional(),
|
||||||
lowStockThreshold: z.number().int().min(0).max(1_000_000).optional(),
|
lowStockThreshold: z.number().int().min(0).max(1_000_000).optional(),
|
||||||
@@ -33,6 +36,11 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
|
|||||||
return NextResponse.json({ error: "No fields to update" }, { status: 400 });
|
return NextResponse.json({ error: "No fields to update" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parsed.data.label) {
|
||||||
|
const taken = await labelTakenError(auth.businessId, parsed.data.label, params.id);
|
||||||
|
if (taken) return NextResponse.json({ error: taken }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
const product = await Product.findOneAndUpdate(
|
const product = await Product.findOneAndUpdate(
|
||||||
{ _id: params.id, businessId: auth.businessId },
|
{ _id: params.id, businessId: auth.businessId },
|
||||||
parsed.data,
|
parsed.data,
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { connectDb } from "@/lib/server/db";
|
import { connectDb } from "@/lib/server/db";
|
||||||
import { Product, BranchPricing } from "@/lib/server/models/Product";
|
import { Product, BranchPricing } from "@/lib/server/models/Product";
|
||||||
import { authorize } from "@/lib/server/auth";
|
import { authorize, MANAGERS } from "@/lib/server/auth";
|
||||||
|
import { PRODUCT_UNITS } from "@/lib/units";
|
||||||
|
import { labelTakenError } from "@/lib/server/products";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
// GET /api/products — the tenant's active catalogue. For branch-scoped
|
// GET /api/products — the tenant's active catalogue (?includeInactive=1
|
||||||
|
// adds deactivated products, for managers and owners). For branch-scoped
|
||||||
// staff the price returned is the price THEIR branch charges (the branch
|
// staff the price returned is the price THEIR branch charges (the branch
|
||||||
// override when one exists), so the terminal shows exactly what the
|
// override when one exists), so the terminal shows exactly what the
|
||||||
// server will record.
|
// server will record.
|
||||||
@@ -17,7 +20,10 @@ export async function GET(req: NextRequest) {
|
|||||||
if (!gate.ok) return gate.response;
|
if (!gate.ok) return gate.response;
|
||||||
const auth = gate.auth;
|
const auth = gate.auth;
|
||||||
|
|
||||||
const products = await Product.find({ businessId: auth.businessId, isActive: true }).sort({ sortOrder: 1 }).lean();
|
const includeInactive =
|
||||||
|
req.nextUrl.searchParams.get("includeInactive") === "1" && MANAGERS.includes(auth.role);
|
||||||
|
const filter = includeInactive ? { businessId: auth.businessId } : { businessId: auth.businessId, isActive: true };
|
||||||
|
const products = await Product.find(filter).sort({ sortOrder: 1 }).lean();
|
||||||
if (auth.branchId) {
|
if (auth.branchId) {
|
||||||
const overrides = await BranchPricing.find({ businessId: auth.businessId, branchId: auth.branchId }).lean();
|
const overrides = await BranchPricing.find({ businessId: auth.businessId, branchId: auth.branchId }).lean();
|
||||||
const priceByProduct = new Map(overrides.map((o) => [o.productId.toString(), o.unitPrice]));
|
const priceByProduct = new Map(overrides.map((o) => [o.productId.toString(), o.unitPrice]));
|
||||||
@@ -31,9 +37,9 @@ export async function GET(req: NextRequest) {
|
|||||||
const createSchema = z.object({
|
const createSchema = z.object({
|
||||||
label: z.string().trim().min(1).max(60),
|
label: z.string().trim().min(1).max(60),
|
||||||
sizeLiters: z.number().positive().max(1000),
|
sizeLiters: z.number().positive().max(1000),
|
||||||
unit: z.string().trim().min(1).max(12).optional(),
|
unit: z.enum(PRODUCT_UNITS).optional(),
|
||||||
category: z.string().trim().min(1).max(40).optional(),
|
category: z.string().trim().min(1).max(40).optional(),
|
||||||
unitPrice: z.number().nonnegative(),
|
unitPrice: z.number().positive("Price must be more than zero"),
|
||||||
sortOrder: z.number().int().optional(),
|
sortOrder: z.number().int().optional(),
|
||||||
lowStockThreshold: z.number().int().min(0).max(1_000_000).optional(),
|
lowStockThreshold: z.number().int().min(0).max(1_000_000).optional(),
|
||||||
});
|
});
|
||||||
@@ -51,6 +57,8 @@ export async function POST(req: NextRequest) {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
const taken = await labelTakenError(auth.businessId, parsed.data.label);
|
||||||
|
if (taken) return NextResponse.json({ error: taken }, { status: 409 });
|
||||||
const product = await Product.create({ ...parsed.data, businessId: auth.businessId });
|
const product = await Product.create({ ...parsed.data, businessId: auth.businessId });
|
||||||
return NextResponse.json({ product }, { status: 201 });
|
return NextResponse.json({ product }, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
|||||||
import { connectDb } from "@/lib/server/db";
|
import { connectDb } from "@/lib/server/db";
|
||||||
import { Purchase } from "@/lib/server/models/Purchase";
|
import { Purchase } from "@/lib/server/models/Purchase";
|
||||||
import { Product } from "@/lib/server/models/Product";
|
import { Product } from "@/lib/server/models/Product";
|
||||||
import { Branch } from "@/lib/server/models/Branch";
|
import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
|
||||||
import { objectId } from "@/lib/server/validation";
|
import { objectId } from "@/lib/server/validation";
|
||||||
import { recordMovement } from "@/lib/server/stock";
|
import { recordMovement } from "@/lib/server/stock";
|
||||||
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
|
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
|
||||||
@@ -91,7 +91,7 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The target branch MUST belong to this tenant.
|
// The target branch MUST belong to this tenant.
|
||||||
const branchDoc = await Branch.findOne({ _id: branchId, businessId: auth.businessId }).lean();
|
const branchDoc = await Branch.findOne({ _id: branchId, businessId: auth.businessId, ...ACTIVE_BRANCH }).lean();
|
||||||
if (!branchDoc) {
|
if (!branchDoc) {
|
||||||
return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
|
return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,32 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import mongoose from "mongoose";
|
import mongoose, { Types } from "mongoose";
|
||||||
import { connectDb } from "@/lib/server/db";
|
import { connectDb } from "@/lib/server/db";
|
||||||
import { Transaction } from "@/lib/server/models/Transaction";
|
import { Transaction } from "@/lib/server/models/Transaction";
|
||||||
import { Purchase } from "@/lib/server/models/Purchase";
|
import { Purchase } from "@/lib/server/models/Purchase";
|
||||||
import { Expense } from "@/lib/server/models/Expense";
|
import { Expense } from "@/lib/server/models/Expense";
|
||||||
import { authorize, badRequest, branchParam, MANAGERS } from "@/lib/server/auth";
|
import { authorize, badRequest, branchParam, MANAGERS } from "@/lib/server/auth";
|
||||||
import { startOfMonthNairobi } from "@/lib/server/businessDay";
|
import { dayRangeNairobi, isBusinessDate, startOfMonthNairobi } from "@/lib/server/businessDay";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
// Management / profit overview — NOT full accounting.
|
/**
|
||||||
// Revenue = sum of SUCCESS sales in range
|
* GET /api/reports/profit?branchId=&fromDate=YYYY-MM-DD&toDate=YYYY-MM-DD
|
||||||
// Cost of Goods= sum of purchase cost in range (stock bought in)
|
*
|
||||||
// Gross Profit = Revenue - Cost of Goods
|
* Management overview, NOT accounting (full accounting is out of the PRD's scope).
|
||||||
// Expenses = sum of operating expenses in range
|
* Revenue SUCCESS sales in the period
|
||||||
// Net Profit = Gross Profit - Expenses
|
* Cost of goods sold units sold x that product's average purchase cost
|
||||||
|
* (all purchases up to the end of the period)
|
||||||
|
* Gross profit revenue - cost of goods sold
|
||||||
|
* Expenses operating expenses in the period
|
||||||
|
* Net profit gross profit - expenses
|
||||||
|
* Shown separately, because buying stock is not the same as using it:
|
||||||
|
* Stock purchased cash spent on stock in the period
|
||||||
|
* Cash surplus revenue - stock purchased - expenses
|
||||||
|
*
|
||||||
|
* Dates are Nairobi business days, inclusive. `from`/`to` (ISO instants)
|
||||||
|
* are still accepted for older clients.
|
||||||
|
*/
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
await connectDb();
|
await connectDb();
|
||||||
// Revenue, costs and profit are owner/manager information, never a cashier's.
|
// Revenue, costs and profit are owner/manager information, never a cashier's.
|
||||||
@@ -28,63 +39,111 @@ export async function GET(req: NextRequest) {
|
|||||||
if (!scope.ok) return scope.response;
|
if (!scope.ok) return scope.response;
|
||||||
const branchId = scope.branchId;
|
const branchId = scope.branchId;
|
||||||
|
|
||||||
// Default range: start of the current business month (EAT) -> now.
|
const range = parseRange(url.searchParams);
|
||||||
const now = new Date();
|
if ("error" in range) return badRequest(range.error);
|
||||||
const from = url.searchParams.get("from") ? new Date(url.searchParams.get("from")!) : startOfMonthNairobi(now);
|
const { from, to } = range;
|
||||||
const to = url.searchParams.get("to") ? new Date(url.searchParams.get("to")!) : now;
|
|
||||||
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) return badRequest("Invalid from/to date");
|
|
||||||
|
|
||||||
const branchObjId = branchId ? new mongoose.Types.ObjectId(branchId) : null;
|
|
||||||
const businessObjId = new mongoose.Types.ObjectId(auth.businessId);
|
const businessObjId = new mongoose.Types.ObjectId(auth.businessId);
|
||||||
const dateMatch = { createdAt: { $gte: from, $lte: to } };
|
const branchObjId = branchId ? new mongoose.Types.ObjectId(branchId) : null;
|
||||||
|
const inPeriod = { businessId: businessObjId, createdAt: { $gte: from, $lt: to }, ...(branchObjId ? { branchId: branchObjId } : {}) };
|
||||||
|
|
||||||
const salesMatch: Record<string, unknown> = { ...dateMatch, businessId: businessObjId, paymentStatus: "SUCCESS" };
|
const [salesByProduct, avgCosts, purchaseAgg, expenseAgg] = await Promise.all([
|
||||||
if (branchObjId) salesMatch.branchId = branchObjId;
|
Transaction.aggregate<{
|
||||||
|
_id: Types.ObjectId;
|
||||||
const costMatch: Record<string, unknown> = { ...dateMatch, businessId: businessObjId };
|
revenue: number;
|
||||||
if (branchObjId) costMatch.branchId = branchObjId;
|
cashTotal: number;
|
||||||
|
mpesaTotal: number;
|
||||||
const [salesAgg, purchaseAgg, expenseAgg] = await Promise.all([
|
units: number;
|
||||||
Transaction.aggregate([
|
lines: number;
|
||||||
{ $match: salesMatch },
|
}>([
|
||||||
|
{ $match: { ...inPeriod, paymentStatus: "SUCCESS" } },
|
||||||
{
|
{
|
||||||
$group: {
|
$group: {
|
||||||
_id: null,
|
_id: "$productId",
|
||||||
revenue: { $sum: "$amountTotal" },
|
revenue: { $sum: "$amountTotal" },
|
||||||
cashTotal: { $sum: { $cond: [{ $eq: ["$paymentMethod", "CASH"] }, "$amountTotal", 0] } },
|
cashTotal: { $sum: { $cond: [{ $eq: ["$paymentMethod", "CASH"] }, "$amountTotal", 0] } },
|
||||||
mpesaTotal: { $sum: { $cond: [{ $eq: ["$paymentMethod", "MPESA"] }, "$amountTotal", 0] } },
|
mpesaTotal: { $sum: { $cond: [{ $eq: ["$paymentMethod", "MPESA"] }, "$amountTotal", 0] } },
|
||||||
salesCount: { $sum: 1 },
|
units: { $sum: "$quantity" },
|
||||||
|
lines: { $sum: 1 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
Purchase.aggregate([
|
// Average buying price per product across the business (suppliers
|
||||||
{ $match: costMatch },
|
// charge every branch alike), using everything bought up to period end.
|
||||||
{ $group: { _id: null, costOfGoods: { $sum: "$totalCost" }, purchaseCount: { $sum: 1 } } },
|
Purchase.aggregate<{ _id: Types.ObjectId; cost: number; qty: number }>([
|
||||||
|
{ $match: { businessId: businessObjId, productId: { $ne: null }, createdAt: { $lt: to } } },
|
||||||
|
{ $group: { _id: "$productId", cost: { $sum: "$totalCost" }, qty: { $sum: "$quantity" } } },
|
||||||
]),
|
]),
|
||||||
Expense.aggregate([
|
Purchase.aggregate<{ total: number; count: number }>([
|
||||||
{ $match: costMatch },
|
{ $match: inPeriod },
|
||||||
{ $group: { _id: null, expenses: { $sum: "$amount" }, expenseCount: { $sum: 1 } } },
|
{ $group: { _id: null, total: { $sum: "$totalCost" }, count: { $sum: 1 } } },
|
||||||
|
]),
|
||||||
|
Expense.aggregate<{ total: number; count: number }>([
|
||||||
|
{ $match: inPeriod },
|
||||||
|
{ $group: { _id: null, total: { $sum: "$amount" }, count: { $sum: 1 } } },
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const revenue = salesAgg[0]?.revenue ?? 0;
|
const avgCost = new Map(avgCosts.filter((c) => c.qty > 0).map((c) => [c._id.toString(), c.cost / c.qty]));
|
||||||
const costOfGoods = purchaseAgg[0]?.costOfGoods ?? 0;
|
let revenue = 0, cashTotal = 0, mpesaTotal = 0, salesCount = 0, unitsSold = 0, unitsWithoutCost = 0, costOfGoodsSold = 0;
|
||||||
const expenses = expenseAgg[0]?.expenses ?? 0;
|
for (const s of salesByProduct) {
|
||||||
const grossProfit = revenue - costOfGoods;
|
revenue += s.revenue;
|
||||||
const netProfit = grossProfit - expenses;
|
cashTotal += s.cashTotal;
|
||||||
|
mpesaTotal += s.mpesaTotal;
|
||||||
|
salesCount += s.lines;
|
||||||
|
unitsSold += s.units;
|
||||||
|
const unitCost = avgCost.get(s._id.toString());
|
||||||
|
if (unitCost === undefined) unitsWithoutCost += s.units;
|
||||||
|
else costOfGoodsSold += s.units * unitCost;
|
||||||
|
}
|
||||||
|
costOfGoodsSold = Math.round(costOfGoodsSold * 100) / 100;
|
||||||
|
|
||||||
|
const stockPurchases = purchaseAgg[0]?.total ?? 0;
|
||||||
|
const expenses = expenseAgg[0]?.total ?? 0;
|
||||||
|
const grossProfit = Math.round((revenue - costOfGoodsSold) * 100) / 100;
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
scope: branchId ? "branch" : "global",
|
scope: branchId ? "branch" : "global",
|
||||||
range: { from: from.toISOString(), to: to.toISOString() },
|
range: { from: from.toISOString(), to: to.toISOString() },
|
||||||
revenue,
|
revenue,
|
||||||
cashTotal: salesAgg[0]?.cashTotal ?? 0,
|
cashTotal,
|
||||||
mpesaTotal: salesAgg[0]?.mpesaTotal ?? 0,
|
mpesaTotal,
|
||||||
salesCount: salesAgg[0]?.salesCount ?? 0,
|
salesCount,
|
||||||
costOfGoods,
|
unitsSold,
|
||||||
purchaseCount: purchaseAgg[0]?.purchaseCount ?? 0,
|
costOfGoodsSold,
|
||||||
|
// Units sold of products never bought through Purchases: their cost
|
||||||
|
// is unknown, so gross profit overstates by their cost.
|
||||||
|
unitsWithoutCost,
|
||||||
grossProfit,
|
grossProfit,
|
||||||
expenses,
|
expenses,
|
||||||
expenseCount: expenseAgg[0]?.expenseCount ?? 0,
|
expenseCount: expenseAgg[0]?.count ?? 0,
|
||||||
netProfit,
|
netProfit: Math.round((grossProfit - expenses) * 100) / 100,
|
||||||
|
stockPurchases,
|
||||||
|
purchaseCount: purchaseAgg[0]?.count ?? 0,
|
||||||
|
cashSurplus: Math.round((revenue - stockPurchases - expenses) * 100) / 100,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The half-open UTC range [from, to) for the request, or a validation error. */
|
||||||
|
function parseRange(params: URLSearchParams): { from: Date; to: Date } | { error: string } {
|
||||||
|
const fromDate = params.get("fromDate");
|
||||||
|
const toDate = params.get("toDate");
|
||||||
|
let from: Date;
|
||||||
|
let to: Date;
|
||||||
|
|
||||||
|
if (fromDate || toDate) {
|
||||||
|
if ((fromDate && !isBusinessDate(fromDate)) || (toDate && !isBusinessDate(toDate))) {
|
||||||
|
return { error: "Dates must be YYYY-MM-DD" };
|
||||||
|
}
|
||||||
|
from = fromDate ? dayRangeNairobi(fromDate).start : startOfMonthNairobi();
|
||||||
|
to = toDate ? dayRangeNairobi(toDate).end : new Date();
|
||||||
|
} else {
|
||||||
|
from = params.get("from") ? new Date(params.get("from")!) : startOfMonthNairobi();
|
||||||
|
to = params.get("to") ? new Date(params.get("to")!) : new Date();
|
||||||
|
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) return { error: "Invalid from/to date" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reversed period must not look like a period with no activity.
|
||||||
|
if (from >= to) return { error: "The start date must be on or before the end date." };
|
||||||
|
return { from, to };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { connectDb } from "@/lib/server/db";
|
import { connectDb } from "@/lib/server/db";
|
||||||
import { Branch } from "@/lib/server/models/Branch";
|
import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
|
||||||
import { Product } from "@/lib/server/models/Product";
|
import { Product } from "@/lib/server/models/Product";
|
||||||
import { StockTransfer, IStockTransfer } from "@/lib/server/models/StockTransfer";
|
import { StockTransfer, IStockTransfer } from "@/lib/server/models/StockTransfer";
|
||||||
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
|
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
|
||||||
@@ -97,7 +97,7 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [branchCount, product] = await Promise.all([
|
const [branchCount, product] = await Promise.all([
|
||||||
Branch.countDocuments({ _id: { $in: [fromBranchId, data.toBranchId] }, businessId: auth.businessId }),
|
Branch.countDocuments({ _id: { $in: [fromBranchId, data.toBranchId] }, businessId: auth.businessId, ...ACTIVE_BRANCH }),
|
||||||
Product.exists({ _id: data.productId, businessId: auth.businessId }),
|
Product.exists({ _id: data.productId, businessId: auth.businessId }),
|
||||||
]);
|
]);
|
||||||
if (branchCount !== 2) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
|
if (branchCount !== 2) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { isValidObjectId } from "mongoose";
|
||||||
|
import { connectDb } from "@/lib/server/db";
|
||||||
|
import { User } from "@/lib/server/models/User";
|
||||||
|
import { authorize, MANAGERS } from "@/lib/server/auth";
|
||||||
|
import { issueInvite } from "@/lib/server/invites";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/users/:id/invite — a new invite link for someone who has not
|
||||||
|
* accepted theirs yet (lost or expired). The previous link stops working.
|
||||||
|
* Owners: anyone in the business. Managers: their own branch's cashiers.
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||||||
|
await connectDb();
|
||||||
|
const gate = authorize(req, MANAGERS);
|
||||||
|
if (!gate.ok) return gate.response;
|
||||||
|
const auth = gate.auth;
|
||||||
|
|
||||||
|
if (!isValidObjectId(params.id)) return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||||
|
const user = await User.findOne({ _id: params.id, businessId: auth.businessId });
|
||||||
|
if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||||
|
|
||||||
|
if (auth.role === "BRANCH_MANAGER" && !(user.role === "DELIVERY" && user.branchId?.toString() === auth.branchId)) {
|
||||||
|
return NextResponse.json({ error: "Branch managers may only invite delivery staff in their own branch" }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (!user.inviteExpiresAt) {
|
||||||
|
return NextResponse.json({ error: "This person has already set their password" }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const inviteUrl = issueInvite(user, req);
|
||||||
|
await user.save();
|
||||||
|
return NextResponse.json({ inviteUrl, expiresAt: user.inviteExpiresAt });
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
|||||||
import { isValidObjectId } from "mongoose";
|
import { isValidObjectId } from "mongoose";
|
||||||
import { connectDb } from "@/lib/server/db";
|
import { connectDb } from "@/lib/server/db";
|
||||||
import { User } from "@/lib/server/models/User";
|
import { User } from "@/lib/server/models/User";
|
||||||
import { Branch } from "@/lib/server/models/Branch";
|
import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
|
||||||
import { Business } from "@/lib/server/models/Business";
|
import { Business } from "@/lib/server/models/Business";
|
||||||
import { UserSession } from "@/lib/server/models/UserSession";
|
import { UserSession } from "@/lib/server/models/UserSession";
|
||||||
import { authorize, MANAGERS } from "@/lib/server/auth";
|
import { authorize, MANAGERS } from "@/lib/server/auth";
|
||||||
@@ -67,7 +67,7 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
|
|||||||
if (target.role === "SUPER_ADMIN") {
|
if (target.role === "SUPER_ADMIN") {
|
||||||
return NextResponse.json({ error: "Owner accounts are not tied to a branch" }, { status: 400 });
|
return NextResponse.json({ error: "Owner accounts are not tied to a branch" }, { status: 400 });
|
||||||
}
|
}
|
||||||
const branch = await Branch.findOne({ _id: branchId, businessId: auth.businessId }).lean();
|
const branch = await Branch.findOne({ _id: branchId, businessId: auth.businessId, ...ACTIVE_BRANCH }).lean();
|
||||||
if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
|
if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
|
||||||
target.branchId = branch._id;
|
target.branchId = branch._id;
|
||||||
}
|
}
|
||||||
@@ -82,5 +82,6 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
|
|||||||
|
|
||||||
const obj = target.toObject() as unknown as Record<string, unknown>;
|
const obj = target.toObject() as unknown as Record<string, unknown>;
|
||||||
delete obj.passwordHash;
|
delete obj.passwordHash;
|
||||||
|
delete obj.inviteTokenHash;
|
||||||
return NextResponse.json({ user: obj });
|
return NextResponse.json({ user: obj });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import bcrypt from "bcryptjs";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { connectDb } from "@/lib/server/db";
|
import { connectDb } from "@/lib/server/db";
|
||||||
import { User } from "@/lib/server/models/User";
|
import { User } from "@/lib/server/models/User";
|
||||||
import { Branch } from "@/lib/server/models/Branch";
|
import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
|
||||||
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
|
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
|
||||||
import { objectId } from "@/lib/server/validation";
|
import { objectId } from "@/lib/server/validation";
|
||||||
import { normalizeKenyanPhone } from "@/lib/server/phone";
|
import { normalizeKenyanPhone } from "@/lib/server/phone";
|
||||||
import { userLimitError, loadTier } from "@/lib/server/subscription";
|
import { userLimitError, loadTier } from "@/lib/server/subscription";
|
||||||
|
import { issueInvite, unusablePasswordHash } from "@/lib/server/invites";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -22,8 +23,11 @@ export async function GET(req: NextRequest) {
|
|||||||
if (!scope.ok) return scope.response;
|
if (!scope.ok) return scope.response;
|
||||||
const filter: Record<string, unknown> = { businessId: auth.businessId };
|
const filter: Record<string, unknown> = { businessId: auth.businessId };
|
||||||
if (scope.branchId) filter.branchId = scope.branchId;
|
if (scope.branchId) filter.branchId = scope.branchId;
|
||||||
const users = await User.find(filter).select("-passwordHash").sort({ name: 1 }).lean();
|
const users = await User.find(filter).select("-passwordHash -inviteTokenHash").sort({ name: 1 }).lean();
|
||||||
return NextResponse.json({ users });
|
// invitePending: the person has not set their password yet.
|
||||||
|
return NextResponse.json({
|
||||||
|
users: users.map((u) => ({ ...u, invitePending: !!u.inviteExpiresAt })),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const createSchema = z.object({
|
const createSchema = z.object({
|
||||||
@@ -32,7 +36,9 @@ const createSchema = z.object({
|
|||||||
branchId: objectId.optional(),
|
branchId: objectId.optional(),
|
||||||
phoneNumber: z.string().min(9).max(15),
|
phoneNumber: z.string().min(9).max(15),
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
password: z.string().min(8),
|
// Omit the password to invite instead: the response carries a one-time
|
||||||
|
// link the person uses to set their own password.
|
||||||
|
password: z.string().min(8).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
@@ -63,7 +69,7 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (branchId) {
|
if (branchId) {
|
||||||
const branch = await Branch.findOne({ _id: branchId, businessId: auth.businessId }).lean();
|
const branch = await Branch.findOne({ _id: branchId, businessId: auth.businessId, ...ACTIVE_BRANCH }).lean();
|
||||||
if (!branch) {
|
if (!branch) {
|
||||||
return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
|
return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
|
||||||
}
|
}
|
||||||
@@ -81,10 +87,10 @@ export async function POST(req: NextRequest) {
|
|||||||
const userErr = await userLimitError(auth.businessId, tier);
|
const userErr = await userLimitError(auth.businessId, tier);
|
||||||
if (userErr) return NextResponse.json({ error: userErr }, { status: 403 });
|
if (userErr) return NextResponse.json({ error: userErr }, { status: 403 });
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(data.password, 12);
|
const passwordHash = data.password ? await bcrypt.hash(data.password, 12) : await unusablePasswordHash();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await User.create({
|
const user = new User({
|
||||||
businessId: auth.businessId,
|
businessId: auth.businessId,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
role: data.role,
|
role: data.role,
|
||||||
@@ -93,6 +99,8 @@ export async function POST(req: NextRequest) {
|
|||||||
email: data.email.toLowerCase(),
|
email: data.email.toLowerCase(),
|
||||||
passwordHash,
|
passwordHash,
|
||||||
});
|
});
|
||||||
|
const inviteUrl = data.password ? null : issueInvite(user, req);
|
||||||
|
await user.save();
|
||||||
|
|
||||||
if (data.role === "BRANCH_MANAGER" && branchId) {
|
if (data.role === "BRANCH_MANAGER" && branchId) {
|
||||||
await Branch.findOneAndUpdate(
|
await Branch.findOneAndUpdate(
|
||||||
@@ -103,7 +111,8 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const obj: any = user.toObject();
|
const obj: any = user.toObject();
|
||||||
delete obj.passwordHash;
|
delete obj.passwordHash;
|
||||||
return NextResponse.json({ user: obj }, { status: 201 });
|
delete obj.inviteTokenHash;
|
||||||
|
return NextResponse.json({ user: obj, inviteUrl }, { status: 201 });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.code === 11000) {
|
if (err?.code === 11000) {
|
||||||
return NextResponse.json({ error: "A user with this phone number or email already exists" }, { status: 409 });
|
return NextResponse.json({ error: "A user with this phone number or email already exists" }, { status: 409 });
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { apiFetch, ApiError } from "@/lib/api";
|
||||||
|
|
||||||
|
interface Invite {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
businessName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"tap-target w-full rounded-xl2 border border-depth-200 bg-white px-4 text-depth-900 shadow-sm transition focus:border-flow-500 focus:outline-none focus:ring-2 focus:ring-flow-500/25";
|
||||||
|
|
||||||
|
/** Where an invited staff member sets their own password. */
|
||||||
|
export default function InvitePage() {
|
||||||
|
const { token } = useParams<{ token: string }>();
|
||||||
|
const router = useRouter();
|
||||||
|
const [invite, setInvite] = useState<Invite | null>(null);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirm, setConfirm] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiFetch<Invite>("/api/invites/" + encodeURIComponent(token))
|
||||||
|
.then(setInvite)
|
||||||
|
.catch((err) => setLoadError(err instanceof ApiError ? err.message : "Could not load this invite."));
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (password.length < 8) return setError("Use at least 8 characters.");
|
||||||
|
if (password !== confirm) return setError("The two passwords do not match.");
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await apiFetch("/api/invites/" + encodeURIComponent(token), {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
router.replace("/login?invited=1");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "Could not save your password.");
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen items-center justify-center bg-sand-50 px-5 py-10">
|
||||||
|
<div className="w-full max-w-md rounded-3xl bg-white p-6 shadow-soft">
|
||||||
|
{loadError ? (
|
||||||
|
<p role="alert" className="text-sm text-alert-600">
|
||||||
|
{loadError}
|
||||||
|
</p>
|
||||||
|
) : !invite ? (
|
||||||
|
<div className="h-32 animate-pulse rounded-xl2 bg-sand-100" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-flow-600">{invite.businessName ?? "Vessel"}</p>
|
||||||
|
<h1 className="mt-1 font-display text-2xl font-bold text-depth-900">Welcome, {invite.name}</h1>
|
||||||
|
<p className="mt-2 text-sm text-depth-600">
|
||||||
|
Choose a password. You will sign in with <strong>{invite.email}</strong> and this password.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="new-password" className="mb-1.5 block text-sm font-medium text-depth-700">
|
||||||
|
New password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="new-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirm-password" className="mb-1.5 block text-sm font-medium text-depth-700">
|
||||||
|
Confirm password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="confirm-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="rounded-lg bg-alert-500/10 px-3 py-2 text-sm text-alert-600">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="tap-target w-full rounded-full bg-gradient-to-r from-flow-500 to-flow-700 font-display font-semibold text-white disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{saving ? "Saving…" : "Set password"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
+11
-1
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { apiFetch, ApiError } from "@/lib/api";
|
import { apiFetch, ApiError } from "@/lib/api";
|
||||||
import { getDeviceId, getDeviceLabel } from "@/lib/deviceId";
|
import { getDeviceId, getDeviceLabel } from "@/lib/deviceId";
|
||||||
@@ -21,6 +21,11 @@ export default function LoginPage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [deviceConflict, setDeviceConflict] = useState<ActiveDevice[] | null>(null);
|
const [deviceConflict, setDeviceConflict] = useState<ActiveDevice[] | null>(null);
|
||||||
|
const [invited, setInvited] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setInvited(new URLSearchParams(window.location.search).get("invited") === "1");
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -174,6 +179,11 @@ export default function LoginPage() {
|
|||||||
<span className="font-medium text-flow-600">Forgot password?</span>
|
<span className="font-medium text-flow-600">Forgot password?</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{invited && !error && (
|
||||||
|
<p className="rounded-lg bg-confirm-500/10 px-3 py-2 text-sm text-confirm-600">
|
||||||
|
Your password is set. Sign in with your email and new password.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="rounded-lg bg-alert-500/10 px-3 py-2 text-sm text-alert-600">
|
<p role="alert" className="rounded-lg bg-alert-500/10 px-3 py-2 text-sm text-alert-600">
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { apiFetch, ApiError } from "@/lib/api";
|
import { apiFetch, ApiError } from "@/lib/api";
|
||||||
import { isValidKenyanPhone } from "@/lib/phone";
|
import { isValidKenyanPhone } from "@/lib/phone";
|
||||||
import { AuthUser, PaymentMethod, Product } from "@/lib/types";
|
import { AuthUser, PaymentMethod, Product, StockLevel } from "@/lib/types";
|
||||||
import {
|
import {
|
||||||
dismissRejected,
|
dismissRejected,
|
||||||
flushQueue,
|
flushQueue,
|
||||||
@@ -13,10 +13,11 @@ import {
|
|||||||
queueSale,
|
queueSale,
|
||||||
registerOfflineSync,
|
registerOfflineSync,
|
||||||
salesFor,
|
salesFor,
|
||||||
|
unsyncedQuantities,
|
||||||
type QueuedSale,
|
type QueuedSale,
|
||||||
type QueueOwner,
|
type QueueOwner,
|
||||||
} from "@/lib/offlineQueue";
|
} from "@/lib/offlineQueue";
|
||||||
import { clearSession, loadSession, saveSession } from "@/lib/sessionCache";
|
import { clearSession, loadSession, saveSession, type StockSnapshot } from "@/lib/sessionCache";
|
||||||
import { MAX_LINES_PER_SALE } from "@/lib/syncProtocol";
|
import { MAX_LINES_PER_SALE } from "@/lib/syncProtocol";
|
||||||
import { useToast } from "@/components/ui/Toast";
|
import { useToast } from "@/components/ui/Toast";
|
||||||
import ProductGrid from "@/components/terminal/ProductGrid";
|
import ProductGrid from "@/components/terminal/ProductGrid";
|
||||||
@@ -51,6 +52,11 @@ export default function TerminalPage() {
|
|||||||
const [products, setProducts] = useState<Product[]>([]);
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
const [productsLoading, setProductsLoading] = useState(true);
|
const [productsLoading, setProductsLoading] = useState(true);
|
||||||
const [cart, setCart] = useState<CartLine[]>([]);
|
const [cart, setCart] = useState<CartLine[]>([]);
|
||||||
|
// Branch stock from the server, minus this phone's sales the server has
|
||||||
|
// not counted yet — so the till deducts stock the moment a sale is
|
||||||
|
// recorded, even offline (PRD 5.3).
|
||||||
|
const [stockSnapshot, setStockSnapshot] = useState<StockSnapshot | null>(null);
|
||||||
|
const [unsynced, setUnsynced] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
const [showCustomer, setShowCustomer] = useState(false);
|
const [showCustomer, setShowCustomer] = useState(false);
|
||||||
const [customerName, setCustomerName] = useState("");
|
const [customerName, setCustomerName] = useState("");
|
||||||
@@ -72,8 +78,37 @@ export default function TerminalPage() {
|
|||||||
|
|
||||||
const refreshCounts = useCallback(async (userId: string) => {
|
const refreshCounts = useCallback(async (userId: string) => {
|
||||||
setCounts(await queueCounts(userId));
|
setCounts(await queueCounts(userId));
|
||||||
|
const snapshotAt = loadSession()?.stock?.fetchedAt ?? null;
|
||||||
|
setUnsynced(unsyncedQuantities(await salesFor(userId), snapshotAt));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const refreshStock = useCallback(
|
||||||
|
async (user: AuthUser) => {
|
||||||
|
const fetchedAt = new Date().toISOString();
|
||||||
|
try {
|
||||||
|
const res = await apiFetch<{ levels: StockLevel[] }>("/api/stock");
|
||||||
|
const snapshot: StockSnapshot = {
|
||||||
|
byProduct: Object.fromEntries(res.levels.map((l) => [l.productId, l.quantity])),
|
||||||
|
fetchedAt,
|
||||||
|
};
|
||||||
|
saveSession({ user, stock: snapshot });
|
||||||
|
setStockSnapshot(snapshot);
|
||||||
|
} catch {
|
||||||
|
// Offline: keep showing the last snapshot minus local sales.
|
||||||
|
const saved = loadSession();
|
||||||
|
if (saved?.user.id === user.id && saved.stock) setStockSnapshot(saved.stock);
|
||||||
|
}
|
||||||
|
await refreshCounts(user.id);
|
||||||
|
},
|
||||||
|
[refreshCounts]
|
||||||
|
);
|
||||||
|
|
||||||
|
const stockByProduct: Record<string, number> | null = stockSnapshot
|
||||||
|
? Object.fromEntries(
|
||||||
|
products.map((p) => [p._id, (stockSnapshot.byProduct[p._id] ?? 0) - (unsynced[p._id] ?? 0)])
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
// Boot: confirm the session online, or fall back to the last confirmed
|
// Boot: confirm the session online, or fall back to the last confirmed
|
||||||
// session on this device so a cashier can keep selling with no network.
|
// session on this device so a cashier can keep selling with no network.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -106,7 +141,7 @@ export default function TerminalPage() {
|
|||||||
show("Offline - using this phone's saved session. Sales will sync when you're back online.", "info");
|
show("Offline - using this phone's saved session. Sales will sync when you're back online.", "info");
|
||||||
}
|
}
|
||||||
setMe(user);
|
setMe(user);
|
||||||
await refreshCounts(user.id);
|
await refreshStock(user);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const productsResult = await apiFetch<{ products: Product[] }>("/api/products");
|
const productsResult = await apiFetch<{ products: Product[] }>("/api/products");
|
||||||
@@ -142,7 +177,10 @@ export default function TerminalPage() {
|
|||||||
const unregister = registerOfflineSync({ id: me.id, businessId: me.businessId, branchId: me.branchId }, (r) => {
|
const unregister = registerOfflineSync({ id: me.id, businessId: me.businessId, branchId: me.branchId }, (r) => {
|
||||||
setCounts((c) => ({ ...c, pending: r.pending }));
|
setCounts((c) => ({ ...c, pending: r.pending }));
|
||||||
setServerReachable(!r.offline);
|
setServerReachable(!r.offline);
|
||||||
if (r.synced > 0) show(r.synced + " saved sale(s) synced", "success");
|
if (r.synced > 0) {
|
||||||
|
show(r.synced + " saved sale(s) synced", "success");
|
||||||
|
refreshStock(me);
|
||||||
|
}
|
||||||
if (r.rejected > 0) {
|
if (r.rejected > 0) {
|
||||||
show(r.rejected + " sale(s) could not be synced - tap the banner to see why", "error");
|
show(r.rejected + " sale(s) could not be synced - tap the banner to see why", "error");
|
||||||
refreshCounts(me.id);
|
refreshCounts(me.id);
|
||||||
@@ -210,6 +248,19 @@ export default function TerminalPage() {
|
|||||||
setFormError("Your account is not assigned to a branch.");
|
setFormError("Your account is not assigned to a branch.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Warn, never block: the jerrycans may be physically there even if the
|
||||||
|
// system was not told (no opening count, an unrecorded delivery), and a
|
||||||
|
// sale that really happened must be recorded. Negative stock then shows
|
||||||
|
// up on the manager's Stock page to be corrected with a count.
|
||||||
|
if (stockByProduct) {
|
||||||
|
const short = lines.filter((l) => l.quantity > (stockByProduct[l.productId] ?? 0));
|
||||||
|
if (short.length > 0) {
|
||||||
|
const detail = short
|
||||||
|
.map((l) => `${l.product.label}: ${Math.max(stockByProduct[l.productId] ?? 0, 0)} in stock, selling ${l.quantity}`)
|
||||||
|
.join("\n");
|
||||||
|
if (!window.confirm(`Not enough stock recorded at this branch:\n${detail}\n\nRecord the sale anyway?`)) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
|
||||||
// The IndexedDB write is the only step that must finish before we show
|
// The IndexedDB write is the only step that must finish before we show
|
||||||
@@ -233,7 +284,7 @@ export default function TerminalPage() {
|
|||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
setScreen({ kind: "receipt", sale });
|
setScreen({ kind: "receipt", sale });
|
||||||
setCart([]);
|
setCart([]);
|
||||||
await refreshCounts(me.id);
|
await refreshCounts(me.id); // deducts this sale from the stock shown
|
||||||
|
|
||||||
// Sync in the background; a failure leaves the sale queued for the
|
// Sync in the background; a failure leaves the sale queued for the
|
||||||
// next automatic retry.
|
// next automatic retry.
|
||||||
@@ -241,6 +292,7 @@ export default function TerminalPage() {
|
|||||||
flushQueue(owner).then(async (r) => {
|
flushQueue(owner).then(async (r) => {
|
||||||
setCounts((c) => ({ ...c, pending: r.pending }));
|
setCounts((c) => ({ ...c, pending: r.pending }));
|
||||||
setServerReachable(!r.offline);
|
setServerReachable(!r.offline);
|
||||||
|
if (r.synced > 0) refreshStock(me);
|
||||||
const rows = await salesFor(me.id);
|
const rows = await salesFor(me.id);
|
||||||
const updated = rows.find((row) => row.clientUuid === sale.clientUuid);
|
const updated = rows.find((row) => row.clientUuid === sale.clientUuid);
|
||||||
if (updated) setScreen((s) => (s.kind === "receipt" && s.sale.clientUuid === sale.clientUuid ? { ...s, sale: updated } : s));
|
if (updated) setScreen((s) => (s.kind === "receipt" && s.sale.clientUuid === sale.clientUuid ? { ...s, sale: updated } : s));
|
||||||
@@ -450,7 +502,7 @@ export default function TerminalPage() {
|
|||||||
{productsLoading ? (
|
{productsLoading ? (
|
||||||
<div className="h-24 animate-pulse rounded-xl2 bg-white shadow-soft" />
|
<div className="h-24 animate-pulse rounded-xl2 bg-white shadow-soft" />
|
||||||
) : (
|
) : (
|
||||||
<ProductGrid products={products} quantities={quantities} onAdd={addProduct} />
|
<ProductGrid products={products} quantities={quantities} stock={stockByProduct} onAdd={addProduct} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{lines.length > 0 && (
|
{lines.length > 0 && (
|
||||||
@@ -491,6 +543,13 @@ export default function TerminalPage() {
|
|||||||
<span>Total</span>
|
<span>Total</span>
|
||||||
<span>KES {total.toLocaleString()}</span>
|
<span>KES {total.toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCart([])}
|
||||||
|
className="mt-2 text-sm font-medium text-alert-600 underline underline-offset-2"
|
||||||
|
>
|
||||||
|
Clear sale
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import { Product } from "@/lib/types";
|
|||||||
interface Props {
|
interface Props {
|
||||||
products: Product[];
|
products: Product[];
|
||||||
quantities: Record<string, number>;
|
quantities: Record<string, number>;
|
||||||
|
/** Stock left at this branch after this phone's own sales; null if unknown. */
|
||||||
|
stock: Record<string, number> | null;
|
||||||
onAdd: (productId: string) => void;
|
onAdd: (productId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One tap adds one unit of a product to the current sale. */
|
/** One tap adds one unit of a product to the current sale. */
|
||||||
export default function ProductGrid({ products, quantities, onAdd }: Props) {
|
export default function ProductGrid({ products, quantities, stock, onAdd }: Props) {
|
||||||
if (products.length === 0) {
|
if (products.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl2 border border-dashed border-depth-200 bg-white p-4 text-center text-sm text-depth-500">
|
<div className="rounded-xl2 border border-dashed border-depth-200 bg-white p-4 text-center text-sm text-depth-500">
|
||||||
@@ -37,6 +39,16 @@ export default function ProductGrid({ products, quantities, onAdd }: Props) {
|
|||||||
>
|
>
|
||||||
<span className="font-display text-sm font-bold leading-tight text-depth-900">{p.label}</span>
|
<span className="font-display text-sm font-bold leading-tight text-depth-900">{p.label}</span>
|
||||||
<span className="font-mono text-xs text-depth-500">KES {p.unitPrice.toLocaleString()}</span>
|
<span className="font-mono text-xs text-depth-500">KES {p.unitPrice.toLocaleString()}</span>
|
||||||
|
{stock && (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"text-xs font-medium " +
|
||||||
|
((stock[p._id] ?? 0) - inCart <= 0 ? "text-alert-600" : "text-depth-500")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{Math.max((stock[p._id] ?? 0) - inCart, 0)} in stock
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{inCart > 0 && (
|
{inCart > 0 && (
|
||||||
<span className="absolute right-2 top-2 flex h-6 min-w-6 items-center justify-center rounded-full bg-flow-500 px-1.5 font-mono text-xs font-bold text-white">
|
<span className="absolute right-2 top-2 flex h-6 min-w-6 items-center justify-center rounded-full bg-flow-500 px-1.5 font-mono text-xs font-bold text-white">
|
||||||
{inCart}
|
{inCart}
|
||||||
|
|||||||
@@ -108,6 +108,23 @@ export async function queueCounts(userId: string): Promise<{ pending: number; re
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Units this cashier has sold that the given server stock snapshot does
|
||||||
|
* not include yet: sales still pending, plus sales that synced after the
|
||||||
|
* snapshot was taken. Subtracting these from the snapshot gives the stock
|
||||||
|
* the till should show right now, online or offline.
|
||||||
|
*/
|
||||||
|
export function unsyncedQuantities(rows: QueuedSale[], snapshotAt: string | null): Record<string, number> {
|
||||||
|
const out: Record<string, number> = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
const notInSnapshot =
|
||||||
|
row.state === "pending" || (row.state === "synced" && (!snapshotAt || (row.syncedAt ?? "") > snapshotAt));
|
||||||
|
if (!notInSnapshot) continue;
|
||||||
|
for (const l of row.lines) out[l.productId] = (out[l.productId] ?? 0) + l.quantity;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** The cashier has acknowledged a rejected sale (e.g. reported it to the manager). */
|
/** The cashier has acknowledged a rejected sale (e.g. reported it to the manager). */
|
||||||
export async function dismissRejected(clientUuid: string): Promise<void> {
|
export async function dismissRejected(clientUuid: string): Promise<void> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import type { IUser } from "@/lib/server/models/User";
|
||||||
|
|
||||||
|
export const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export function hashInviteToken(token: string): string {
|
||||||
|
return crypto.createHash("sha256").update(token).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A password nobody knows, for an account whose owner has not accepted their invite yet. */
|
||||||
|
export async function unusablePasswordHash(): Promise<string> {
|
||||||
|
return bcrypt.hash(crypto.randomBytes(32).toString("hex"), 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give the user a fresh invite (any earlier link stops working) and return
|
||||||
|
* the link to send them. The caller saves the user.
|
||||||
|
*/
|
||||||
|
export function issueInvite(user: IUser, req: NextRequest): string {
|
||||||
|
const token = crypto.randomBytes(32).toString("base64url");
|
||||||
|
user.inviteTokenHash = hashInviteToken(token);
|
||||||
|
user.inviteExpiresAt = new Date(Date.now() + INVITE_TTL_MS);
|
||||||
|
const origin = process.env.APP_URL?.replace(/\/$/, "") || req.nextUrl.origin;
|
||||||
|
return `${origin}/invite/${token}`;
|
||||||
|
}
|
||||||
@@ -6,6 +6,12 @@ export interface IBranch extends Document {
|
|||||||
branchName: string;
|
branchName: string;
|
||||||
locationCity: string;
|
locationCity: string;
|
||||||
managerId: Types.ObjectId | null;
|
managerId: Types.ObjectId | null;
|
||||||
|
// A deactivated branch keeps all its sales, purchases and stock history
|
||||||
|
// and still appears in reports for past periods, but takes no new staff,
|
||||||
|
// transfers or purchases and does not count towards the plan's limit.
|
||||||
|
// Rows created before this field existed have no value: treat as active
|
||||||
|
// (query with `isActive: { $ne: false }`).
|
||||||
|
isActive: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@@ -16,9 +22,13 @@ const branchSchema = new Schema<IBranch>(
|
|||||||
branchName: { type: String, required: true, trim: true },
|
branchName: { type: String, required: true, trim: true },
|
||||||
locationCity: { type: String, required: true, trim: true },
|
locationCity: { type: String, required: true, trim: true },
|
||||||
managerId: { type: Schema.Types.ObjectId, ref: "User", default: null },
|
managerId: { type: Schema.Types.ObjectId, ref: "User", default: null },
|
||||||
|
isActive: { type: Boolean, default: true },
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Filter for branches that are in use. */
|
||||||
|
export const ACTIVE_BRANCH = { isActive: { $ne: false } } as const;
|
||||||
|
|
||||||
export const Branch =
|
export const Branch =
|
||||||
(models.Branch as Model<IBranch>) || model<IBranch>("Branch", branchSchema);
|
(models.Branch as Model<IBranch>) || model<IBranch>("Branch", branchSchema);
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ export interface IProduct extends Document {
|
|||||||
_id: Types.ObjectId;
|
_id: Types.ObjectId;
|
||||||
businessId: Types.ObjectId; // tenant this product belongs to
|
businessId: Types.ObjectId; // tenant this product belongs to
|
||||||
label: string;
|
label: string;
|
||||||
|
// Size measured in `unit` (litres for water, a count for side items).
|
||||||
|
// The field keeps its historical name so existing data needs no migration.
|
||||||
sizeLiters: number;
|
sizeLiters: number;
|
||||||
unit: string; // display unit, e.g. "L" for water, "pcs" / "tray" for eggs
|
unit: string; // see PRODUCT_UNITS in src/lib/units.ts: "L", "pcs", "tray"...
|
||||||
category: string; // e.g. "Water", "Eggs"
|
category: string; // e.g. "Water", "Eggs"
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ export interface IUser extends Document {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
failedLoginAttempts: number;
|
failedLoginAttempts: number;
|
||||||
lockedUntil: Date | null;
|
lockedUntil: Date | null;
|
||||||
|
// Pending invite: the person sets their own password through a one-time
|
||||||
|
// link. Only the SHA-256 of the token is stored. Until they accept, the
|
||||||
|
// account has a random unknown password, so it cannot be signed into.
|
||||||
|
inviteTokenHash: string | null;
|
||||||
|
inviteExpiresAt: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|
||||||
@@ -34,6 +39,8 @@ const userSchema = new Schema<IUser>(
|
|||||||
isActive: { type: Boolean, default: true },
|
isActive: { type: Boolean, default: true },
|
||||||
failedLoginAttempts: { type: Number, default: 0 },
|
failedLoginAttempts: { type: Number, default: 0 },
|
||||||
lockedUntil: { type: Date, default: null },
|
lockedUntil: { type: Date, default: null },
|
||||||
|
inviteTokenHash: { type: String, default: null, index: { sparse: true } },
|
||||||
|
inviteExpiresAt: { type: Date, default: null },
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Types } from "mongoose";
|
||||||
|
import { Product } from "@/lib/server/models/Product";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two products with the same name (ignoring case) would be impossible to
|
||||||
|
* tell apart on the terminal and in reports, so names are unique per
|
||||||
|
* business — including deactivated products, which can be reactivated.
|
||||||
|
*/
|
||||||
|
export async function labelTakenError(
|
||||||
|
businessId: Types.ObjectId | string,
|
||||||
|
label: string,
|
||||||
|
exceptId?: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const filter: Record<string, unknown> = { businessId, label: label.trim() };
|
||||||
|
if (exceptId) filter._id = { $ne: exceptId };
|
||||||
|
const clash = await Product.findOne(filter).collation({ locale: "en", strength: 2 }).select("label isActive").lean();
|
||||||
|
if (!clash) return null;
|
||||||
|
return clash.isActive
|
||||||
|
? `A product called "${clash.label}" already exists.`
|
||||||
|
: `A deactivated product called "${clash.label}" already exists. Reactivate it instead.`;
|
||||||
|
}
|
||||||
+40
-5
@@ -1,7 +1,8 @@
|
|||||||
import mongoose, { Types } from "mongoose";
|
import mongoose, { Types } from "mongoose";
|
||||||
import { StockMovement, type MovementType } from "@/lib/server/models/StockMovement";
|
import { StockMovement, type MovementType } from "@/lib/server/models/StockMovement";
|
||||||
import { Branch } from "@/lib/server/models/Branch";
|
import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
|
||||||
import { Product } from "@/lib/server/models/Product";
|
import { Product } from "@/lib/server/models/Product";
|
||||||
|
import { Purchase } from "@/lib/server/models/Purchase";
|
||||||
import type { ITransaction } from "@/lib/server/models/Transaction";
|
import type { ITransaction } from "@/lib/server/models/Transaction";
|
||||||
|
|
||||||
type Id = Types.ObjectId | string;
|
type Id = Types.ObjectId | string;
|
||||||
@@ -89,6 +90,7 @@ export interface StockLevel {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
lowStockThreshold: number;
|
lowStockThreshold: number;
|
||||||
status: StockStatus;
|
status: StockStatus;
|
||||||
|
productActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stockStatus(quantity: number, threshold: number): StockStatus {
|
export function stockStatus(quantity: number, threshold: number): StockStatus {
|
||||||
@@ -98,20 +100,22 @@ export function stockStatus(quantity: number, threshold: number): StockStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stock of every active product at every branch in scope — including
|
* Stock of every active product at every active branch in scope —
|
||||||
* zero rows, so an owner sees "0" rather than a missing product. A
|
* including zero rows, so an owner sees "0" rather than a missing product.
|
||||||
|
* A deactivated product is listed too wherever it still has stock, so
|
||||||
|
* units bought before deactivation never silently disappear. A
|
||||||
* negative quantity is reported as-is: it means sales were recorded
|
* negative quantity is reported as-is: it means sales were recorded
|
||||||
* (typically offline) for stock the ledger never received, and the branch
|
* (typically offline) for stock the ledger never received, and the branch
|
||||||
* needs a count/adjustment.
|
* needs a count/adjustment.
|
||||||
*/
|
*/
|
||||||
export async function getStockLevels(businessId: Id, branchId?: Id | null): Promise<StockLevel[]> {
|
export async function getStockLevels(businessId: Id, branchId?: Id | null): Promise<StockLevel[]> {
|
||||||
const bizObj = new mongoose.Types.ObjectId(businessId.toString());
|
const bizObj = new mongoose.Types.ObjectId(businessId.toString());
|
||||||
const branchFilter: Record<string, unknown> = { businessId: bizObj };
|
const branchFilter: Record<string, unknown> = { businessId: bizObj, ...ACTIVE_BRANCH };
|
||||||
if (branchId) branchFilter._id = new mongoose.Types.ObjectId(branchId.toString());
|
if (branchId) branchFilter._id = new mongoose.Types.ObjectId(branchId.toString());
|
||||||
|
|
||||||
const [branches, products, totals] = await Promise.all([
|
const [branches, products, totals] = await Promise.all([
|
||||||
Branch.find(branchFilter).sort({ branchName: 1 }).lean(),
|
Branch.find(branchFilter).sort({ branchName: 1 }).lean(),
|
||||||
Product.find({ businessId: bizObj, isActive: true }).sort({ sortOrder: 1 }).lean(),
|
Product.find({ businessId: bizObj }).sort({ sortOrder: 1 }).lean(),
|
||||||
StockMovement.aggregate<{ _id: { branchId: Types.ObjectId; productId: Types.ObjectId }; total: number }>([
|
StockMovement.aggregate<{ _id: { branchId: Types.ObjectId; productId: Types.ObjectId }; total: number }>([
|
||||||
{ $match: branchId ? { businessId: bizObj, branchId: branchFilter._id } : { businessId: bizObj } },
|
{ $match: branchId ? { businessId: bizObj, branchId: branchFilter._id } : { businessId: bizObj } },
|
||||||
{ $group: { _id: { branchId: "$branchId", productId: "$productId" }, total: { $sum: "$quantity" } } },
|
{ $group: { _id: { branchId: "$branchId", productId: "$productId" }, total: { $sum: "$quantity" } } },
|
||||||
@@ -123,6 +127,7 @@ export async function getStockLevels(businessId: Id, branchId?: Id | null): Prom
|
|||||||
for (const b of branches) {
|
for (const b of branches) {
|
||||||
for (const p of products) {
|
for (const p of products) {
|
||||||
const quantity = totalByKey.get(`${b._id}:${p._id}`) ?? 0;
|
const quantity = totalByKey.get(`${b._id}:${p._id}`) ?? 0;
|
||||||
|
if (!p.isActive && quantity === 0) continue;
|
||||||
const lowStockThreshold = p.lowStockThreshold ?? 10;
|
const lowStockThreshold = p.lowStockThreshold ?? 10;
|
||||||
levels.push({
|
levels.push({
|
||||||
branchId: b._id.toString(),
|
branchId: b._id.toString(),
|
||||||
@@ -133,8 +138,38 @@ export async function getStockLevels(businessId: Id, branchId?: Id | null): Prom
|
|||||||
quantity,
|
quantity,
|
||||||
lowStockThreshold,
|
lowStockThreshold,
|
||||||
status: stockStatus(quantity, lowStockThreshold),
|
status: stockStatus(quantity, lowStockThreshold),
|
||||||
|
productActive: p.isActive,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return levels;
|
return levels;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the PURCHASE movement for every catalogue purchase that has none.
|
||||||
|
* Purchases recorded before the stock ledger existed (or while a write
|
||||||
|
* failed) otherwise never show up as stock. Idempotent: run it as often
|
||||||
|
* as you like. Returns how many movements were added.
|
||||||
|
*/
|
||||||
|
export async function backfillPurchaseMovements(businessId?: Id): Promise<number> {
|
||||||
|
const filter: Record<string, unknown> = { productId: { $ne: null } };
|
||||||
|
if (businessId) filter.businessId = new mongoose.Types.ObjectId(businessId.toString());
|
||||||
|
|
||||||
|
let added = 0;
|
||||||
|
for await (const p of Purchase.find(filter).lean().cursor()) {
|
||||||
|
const refId = p._id.toString();
|
||||||
|
if (await StockMovement.exists({ type: "PURCHASE", refId })) continue;
|
||||||
|
await recordMovement({
|
||||||
|
businessId: p.businessId,
|
||||||
|
branchId: p.branchId,
|
||||||
|
productId: p.productId!,
|
||||||
|
type: "PURCHASE",
|
||||||
|
quantity: p.quantity,
|
||||||
|
refId,
|
||||||
|
note: `From ${p.supplierName} (backfilled)`,
|
||||||
|
recordedBy: p.recordedBy,
|
||||||
|
});
|
||||||
|
added += 1;
|
||||||
|
}
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,25 +8,27 @@ import { Types } from "mongoose";
|
|||||||
import { Branch } from "@/lib/server/models/Branch";
|
import { Branch } from "@/lib/server/models/Branch";
|
||||||
import { User } from "@/lib/server/models/User";
|
import { User } from "@/lib/server/models/User";
|
||||||
import { Business, IBusiness } from "@/lib/server/models/Business";
|
import { Business, IBusiness } from "@/lib/server/models/Business";
|
||||||
import { TIERS, isUsable, type TierId, type SubscriptionStatus } from "@/lib/tiers";
|
import { TIERS, effectiveMaxBranches, isUsable, type TierId, type SubscriptionStatus } from "@/lib/tiers";
|
||||||
|
|
||||||
export function tierOf(business: Pick<IBusiness, "tier">): TierId {
|
export function tierOf(business: Pick<IBusiness, "tier">): TierId {
|
||||||
return (business.tier as TierId) ?? "STARTER";
|
return (business.tier as TierId) ?? "STARTER";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an error message string if adding one more branch would exceed
|
* Returns an error message string if adding (or reactivating) one more
|
||||||
* the business's plan, or null if it's allowed.
|
* branch would exceed the business's plan, or null if it's allowed. Only
|
||||||
|
* active branches count; trials get TRIAL_MAX_BRANCHES.
|
||||||
*/
|
*/
|
||||||
export async function branchLimitError(
|
export async function branchLimitError(businessId: Types.ObjectId | string): Promise<string | null> {
|
||||||
businessId: Types.ObjectId | string,
|
const biz = await Business.findById(businessId).select("tier subscriptionStatus").lean();
|
||||||
tier: TierId
|
const tier = (biz?.tier as TierId) ?? "STARTER";
|
||||||
): Promise<string | null> {
|
const status = (biz?.subscriptionStatus as SubscriptionStatus) ?? "TRIALING";
|
||||||
const max = TIERS[tier].maxBranches;
|
const max = effectiveMaxBranches(tier, status);
|
||||||
if (max === null) return null; // unlimited
|
if (max === null) return null; // unlimited
|
||||||
const count = await Branch.countDocuments({ businessId });
|
const count = await Branch.countDocuments({ businessId, isActive: { $ne: false } });
|
||||||
if (count >= max) {
|
if (count >= max) {
|
||||||
return `Your ${TIERS[tier].name} plan allows ${max} branch${max === 1 ? "" : "es"}. Upgrade to add more.`;
|
const plan = status === "TRIALING" ? "free trial" : `${TIERS[tier].name} plan`;
|
||||||
|
return `Your ${plan} allows ${max} active branch${max === 1 ? "" : "es"}. Upgrade to add more.`;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,17 @@ import type { AuthUser, Product } from "@/lib/types";
|
|||||||
|
|
||||||
const KEY = "aquaflow_terminal_session";
|
const KEY = "aquaflow_terminal_session";
|
||||||
|
|
||||||
|
/** The branch's stock as the server last reported it. */
|
||||||
|
export interface StockSnapshot {
|
||||||
|
byProduct: Record<string, number>;
|
||||||
|
fetchedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionSnapshot {
|
export interface SessionSnapshot {
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
features: { mpesa: boolean };
|
features: { mpesa: boolean };
|
||||||
products: Product[];
|
products: Product[];
|
||||||
|
stock?: StockSnapshot;
|
||||||
savedAt: string;
|
savedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +39,7 @@ export function saveSession(update: Partial<SessionSnapshot> & { user: AuthUser
|
|||||||
const next: SessionSnapshot = {
|
const next: SessionSnapshot = {
|
||||||
features: update.features ?? (sameUser ? prev!.features : { mpesa: false }),
|
features: update.features ?? (sameUser ? prev!.features : { mpesa: false }),
|
||||||
products: update.products ?? (sameUser ? prev!.products : []),
|
products: update.products ?? (sameUser ? prev!.products : []),
|
||||||
|
stock: update.stock ?? (sameUser ? prev!.stock : undefined),
|
||||||
user: update.user,
|
user: update.user,
|
||||||
savedAt: new Date().toISOString(),
|
savedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -66,6 +66,19 @@ export const DEFAULT_TIER: TierId = "STARTER";
|
|||||||
// Days of free trial a brand-new business gets on signup.
|
// Days of free trial a brand-new business gets on signup.
|
||||||
export const TRIAL_DAYS = 14;
|
export const TRIAL_DAYS = 14;
|
||||||
|
|
||||||
|
// Branches allowed while a business is TRIALING, whatever its tier, so a
|
||||||
|
// new owner can try the multi-branch features (transfers, per-branch
|
||||||
|
// stock and reports) before paying. After the trial the tier's own limit
|
||||||
|
// applies to adding branches; existing branches are never removed.
|
||||||
|
export const TRIAL_MAX_BRANCHES = 2;
|
||||||
|
|
||||||
|
/** Branch limit for a business right now (null = unlimited). */
|
||||||
|
export function effectiveMaxBranches(tier: TierId, status: SubscriptionStatus): number | null {
|
||||||
|
const max = TIERS[tier].maxBranches;
|
||||||
|
if (status !== "TRIALING" || max === null) return max;
|
||||||
|
return Math.max(max, TRIAL_MAX_BRANCHES);
|
||||||
|
}
|
||||||
|
|
||||||
export type SubscriptionStatus = "TRIALING" | "ACTIVE" | "PAST_DUE" | "SUSPENDED";
|
export type SubscriptionStatus = "TRIALING" | "ACTIVE" | "PAST_DUE" | "SUSPENDED";
|
||||||
|
|
||||||
export function formatKsh(amount: number): string {
|
export function formatKsh(amount: number): string {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface Branch {
|
|||||||
branchName: string;
|
branchName: string;
|
||||||
locationCity: string;
|
locationCity: string;
|
||||||
managerId: string | null;
|
managerId: string | null;
|
||||||
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
@@ -94,6 +95,7 @@ export interface StockLevel {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
lowStockThreshold: number;
|
lowStockThreshold: number;
|
||||||
status: "OK" | "LOW" | "OUT";
|
status: "OK" | "LOW" | "OUT";
|
||||||
|
productActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StockMovementRow {
|
export interface StockMovementRow {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Units a product can be measured in. Water refills are sized in litres;
|
||||||
|
// side items (eggs, bottles, bags) are counted.
|
||||||
|
export const PRODUCT_UNITS = ["L", "ml", "kg", "g", "pcs", "tray", "pack"] as const;
|
||||||
|
export type ProductUnit = (typeof PRODUCT_UNITS)[number];
|
||||||
|
|
||||||
|
export const UNIT_LABELS: Record<ProductUnit, string> = {
|
||||||
|
L: "litres",
|
||||||
|
ml: "millilitres",
|
||||||
|
kg: "kilograms",
|
||||||
|
g: "grams",
|
||||||
|
pcs: "pieces",
|
||||||
|
tray: "trays",
|
||||||
|
pack: "packs",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** "20L", "500ml", "30 pcs", "1 tray". */
|
||||||
|
export function formatSize(size: number, unit: string | undefined): string {
|
||||||
|
const u = unit || "L";
|
||||||
|
return u === "L" || u === "ml" || u === "kg" || u === "g" ? `${size}${u}` : `${size} ${u}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import mongoose from "mongoose";
|
||||||
|
import { Business } from "@/lib/server/models/Business";
|
||||||
|
import { Branch } from "@/lib/server/models/Branch";
|
||||||
|
import { User } from "@/lib/server/models/User";
|
||||||
|
import { Product } from "@/lib/server/models/Product";
|
||||||
|
import { Purchase } from "@/lib/server/models/Purchase";
|
||||||
|
import { backfillPurchaseMovements, stockOf, getStockLevels } from "@/lib/server/stock";
|
||||||
|
import * as branches from "@/app/api/branches/route";
|
||||||
|
import * as branchById from "@/app/api/branches/[id]/route";
|
||||||
|
import * as users from "@/app/api/users/route";
|
||||||
|
import * as invites from "@/app/api/invites/[token]/route";
|
||||||
|
import * as reinvite from "@/app/api/users/[id]/invite/route";
|
||||||
|
import * as login from "@/app/api/auth/login/route";
|
||||||
|
import * as products from "@/app/api/products/route";
|
||||||
|
import * as productById from "@/app/api/products/[id]/route";
|
||||||
|
import * as purchases from "@/app/api/purchases/route";
|
||||||
|
import * as profit from "@/app/api/reports/profit/route";
|
||||||
|
import * as cash from "@/app/api/transactions/cash/route";
|
||||||
|
import * as adjustments from "@/app/api/stock/adjustments/route";
|
||||||
|
import * as transfers from "@/app/api/stock/transfers/route";
|
||||||
|
import { json, makeTenant, params, req, uuid, type Tenant } from "./helpers";
|
||||||
|
|
||||||
|
async function setPlan(t: Tenant, tier: string, subscriptionStatus: string) {
|
||||||
|
await Business.updateOne({ _id: t.businessId }, { tier, subscriptionStatus });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newBranch = (name: string) => ({ branchName: name, locationCity: "Nakuru" });
|
||||||
|
|
||||||
|
describe("F-01 trial branch allowance", () => {
|
||||||
|
it("lets a trialling Starter business run two branches, not three", async () => {
|
||||||
|
const t = await makeTenant(); // already has branches A and B
|
||||||
|
await setPlan(t, "STARTER", "TRIALING");
|
||||||
|
const third = await branches.POST(req("POST", "/x", { as: t.owner, body: newBranch("C") }));
|
||||||
|
expect(third.status).toBe(403);
|
||||||
|
expect((await third.json()).error).toMatch(/free trial allows 2/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts only active branches, and a paid Starter plan allows one", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
await setPlan(t, "STARTER", "TRIALING");
|
||||||
|
await Branch.updateOne({ _id: t.branchB }, { isActive: false });
|
||||||
|
expect((await branches.POST(req("POST", "/x", { as: t.owner, body: newBranch("C") }))).status).toBe(201);
|
||||||
|
|
||||||
|
await setPlan(t, "STARTER", "ACTIVE");
|
||||||
|
expect((await branches.POST(req("POST", "/x", { as: t.owner, body: newBranch("D") }))).status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("F-06 branch deactivation", () => {
|
||||||
|
it("refuses while the branch still holds stock or active staff, then succeeds once emptied", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
await adjustments.POST(
|
||||||
|
req("POST", "/x", { as: t.owner, body: { clientUuid: uuid(), branchId: t.branchB, productId: t.water20, mode: "DELTA", quantity: 4 } })
|
||||||
|
);
|
||||||
|
const deactivate = () =>
|
||||||
|
branchById.PATCH(req("PATCH", "/x", { as: t.owner, body: { isActive: false } }), params({ id: t.branchB }));
|
||||||
|
|
||||||
|
const withStock = await deactivate();
|
||||||
|
expect(withStock.status).toBe(409);
|
||||||
|
expect((await withStock.json()).error).toMatch(/4 x 20L Jerrycan/);
|
||||||
|
|
||||||
|
await transfers.POST(
|
||||||
|
req("POST", "/x", {
|
||||||
|
as: t.owner,
|
||||||
|
body: { clientUuid: uuid(), fromBranchId: t.branchB, toBranchId: t.branchA, productId: t.water20, quantity: 4 },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const withStaff = await deactivate();
|
||||||
|
expect(withStaff.status).toBe(409);
|
||||||
|
expect((await withStaff.json()).error).toMatch(/active staff/);
|
||||||
|
|
||||||
|
await User.updateOne({ _id: t.cashierB.id }, { isActive: false });
|
||||||
|
expect((await deactivate()).status).toBe(200);
|
||||||
|
|
||||||
|
// Hidden from pickers, kept on the Branches page.
|
||||||
|
const active = await json(await branches.GET(req("GET", "/api/branches", { as: t.owner })));
|
||||||
|
const all = await json(await branches.GET(req("GET", "/api/branches?includeInactive=1", { as: t.owner })));
|
||||||
|
expect(active.body.branches.map((b: any) => b._id)).toEqual([t.branchA]);
|
||||||
|
expect(all.body.branches).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("will not reactivate a branch past the plan's limit", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
await Branch.updateOne({ _id: t.branchB }, { isActive: false });
|
||||||
|
await setPlan(t, "STARTER", "ACTIVE");
|
||||||
|
const res = await branchById.PATCH(req("PATCH", "/x", { as: t.owner, body: { isActive: true } }), params({ id: t.branchB }));
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("F-03 report period validation", () => {
|
||||||
|
it("rejects a reversed period instead of showing an empty report", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
const res = await profit.GET(req("GET", "/api/reports/profit?fromDate=2026-10-12&toDate=2026-10-11", { as: t.owner }));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const sameDay = await profit.GET(req("GET", "/api/reports/profit?fromDate=2026-10-11&toDate=2026-10-11", { as: t.owner }));
|
||||||
|
expect(sameDay.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("F-07 cost of goods sold", () => {
|
||||||
|
it("costs only the units sold, at the average buying price, and reports purchases separately", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
const buy = (quantity: number, unitCost: number) =>
|
||||||
|
purchases.POST(req("POST", "/x", { as: t.managerA, body: { supplierName: "Kiwanja", productId: t.water20, quantity, unitCost } }));
|
||||||
|
await buy(20, 10);
|
||||||
|
await buy(10, 13); // average (200 + 130) / 30 = 11
|
||||||
|
await cash.POST(
|
||||||
|
req("POST", "/x", {
|
||||||
|
as: t.cashierA,
|
||||||
|
body: { sales: [{ clientUuid: uuid(), lines: [{ productId: t.water20, quantity: 3 }, { productId: t.water5, quantity: 1 }] }] },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const { body } = await json(await profit.GET(req("GET", "/api/reports/profit", { as: t.owner })));
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
revenue: 660, // 3 x 200 + 1 x 60
|
||||||
|
unitsSold: 4,
|
||||||
|
costOfGoodsSold: 33, // 3 x 11; the 5L was never purchased
|
||||||
|
unitsWithoutCost: 1,
|
||||||
|
grossProfit: 627,
|
||||||
|
stockPurchases: 330,
|
||||||
|
cashSurplus: 330,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("purchase-to-stock backfill", () => {
|
||||||
|
it("turns pre-ledger purchases into stock, once", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
await Purchase.create({
|
||||||
|
businessId: t.businessId,
|
||||||
|
branchId: t.branchA,
|
||||||
|
supplierName: "Old supplier",
|
||||||
|
productId: t.water20,
|
||||||
|
productLabel: "20L Jerrycan",
|
||||||
|
quantity: 20,
|
||||||
|
unitCost: 10,
|
||||||
|
totalCost: 200,
|
||||||
|
recordedBy: t.owner.id,
|
||||||
|
});
|
||||||
|
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(0);
|
||||||
|
expect(await backfillPurchaseMovements()).toBe(1);
|
||||||
|
expect(await backfillPurchaseMovements()).toBe(0);
|
||||||
|
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("F-04 / F-05 / CAT-12 catalogue", () => {
|
||||||
|
it("lists deactivated products for managers and keeps their stock visible", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
await adjustments.POST(
|
||||||
|
req("POST", "/x", { as: t.owner, body: { clientUuid: uuid(), branchId: t.branchA, productId: t.water5, mode: "DELTA", quantity: 7 } })
|
||||||
|
);
|
||||||
|
await Product.updateOne({ _id: t.water5 }, { isActive: false });
|
||||||
|
|
||||||
|
const forManager = await json(await products.GET(req("GET", "/api/products?includeInactive=1", { as: t.managerA })));
|
||||||
|
const forCashier = await json(await products.GET(req("GET", "/api/products?includeInactive=1", { as: t.cashierA })));
|
||||||
|
expect(forManager.body.products).toHaveLength(2);
|
||||||
|
expect(forCashier.body.products).toHaveLength(1);
|
||||||
|
|
||||||
|
const levels = await getStockLevels(t.businessId);
|
||||||
|
expect(levels.find((l) => l.productId === t.water5 && l.branchId === t.branchA)).toMatchObject({
|
||||||
|
quantity: 7,
|
||||||
|
productActive: false,
|
||||||
|
});
|
||||||
|
expect(levels.some((l) => l.productId === t.water5 && l.branchId === t.branchB)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores count units, refuses duplicate names and zero prices", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
const create = (body: object) => products.POST(req("POST", "/x", { as: t.owner, body }));
|
||||||
|
const tray = await json(await create({ label: "Tray of Eggs", sizeLiters: 30, unit: "pcs", unitPrice: 400 }));
|
||||||
|
expect(tray.status).toBe(201);
|
||||||
|
expect(tray.body.product.unit).toBe("pcs");
|
||||||
|
|
||||||
|
expect((await create({ label: "tray of eggs", sizeLiters: 1, unit: "tray", unitPrice: 400 })).status).toBe(409);
|
||||||
|
expect((await create({ label: "Free sample", sizeLiters: 1, unit: "pcs", unitPrice: 0 })).status).toBe(400);
|
||||||
|
expect((await create({ label: "Bucket", sizeLiters: 1, unit: "buckets", unitPrice: 50 })).status).toBe(400);
|
||||||
|
|
||||||
|
const rename = await productById.PATCH(
|
||||||
|
req("PATCH", "/x", { as: t.owner, body: { label: "20L JERRYCAN" } }),
|
||||||
|
params({ id: t.water5 })
|
||||||
|
);
|
||||||
|
expect(rename.status).toBe(409);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("STA-04 staff invites", () => {
|
||||||
|
async function invite(t: Tenant) {
|
||||||
|
const res = await json(
|
||||||
|
await users.POST(
|
||||||
|
req("POST", "/api/users", {
|
||||||
|
as: t.managerA,
|
||||||
|
body: { name: "New Cashier", role: "DELIVERY", phoneNumber: "0711222333", email: "new.cashier@aquaflow.test" },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
return { userId: res.body.user._id as string, token: (res.body.inviteUrl as string).split("/invite/")[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const signIn = (password: string) =>
|
||||||
|
login.POST(req("POST", "/x", { body: { email: "new.cashier@aquaflow.test", password, deviceId: uuid() } }));
|
||||||
|
|
||||||
|
it("lets the invited person set their own password, once", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
const { token } = await invite(t);
|
||||||
|
|
||||||
|
const who = await json(await invites.GET(req("GET", "/x"), params({ token })));
|
||||||
|
expect(who.body).toMatchObject({ name: "New Cashier", email: "new.cashier@aquaflow.test" });
|
||||||
|
|
||||||
|
expect((await invites.POST(req("POST", "/x", { body: { password: "short" } }), params({ token }))).status).toBe(400);
|
||||||
|
expect((await invites.POST(req("POST", "/x", { body: { password: "my-own-secret" } }), params({ token }))).status).toBe(200);
|
||||||
|
expect((await signIn("my-own-secret")).status).toBe(200);
|
||||||
|
|
||||||
|
// The link is single-use.
|
||||||
|
expect((await invites.GET(req("GET", "/x"), params({ token }))).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("issues a fresh link that replaces the old one, and rejects expired links", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
const { userId, token } = await invite(t);
|
||||||
|
const again = await json(await reinvite.POST(req("POST", "/x", { as: t.managerA }), params({ id: userId })));
|
||||||
|
expect(again.status).toBe(200);
|
||||||
|
const fresh = (again.body.inviteUrl as string).split("/invite/")[1];
|
||||||
|
|
||||||
|
expect((await invites.GET(req("GET", "/x"), params({ token }))).status).toBe(404);
|
||||||
|
expect((await invites.GET(req("GET", "/x"), params({ token: fresh }))).status).toBe(200);
|
||||||
|
|
||||||
|
await User.updateOne({ _id: new mongoose.Types.ObjectId(userId) }, { inviteExpiresAt: new Date(Date.now() - 1000) });
|
||||||
|
expect((await invites.GET(req("GET", "/x"), params({ token: fresh }))).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a manager re-invite another branch's staff", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
await User.updateOne({ _id: t.cashierB.id }, { inviteExpiresAt: new Date(Date.now() + 60_000) });
|
||||||
|
const res = await reinvite.POST(req("POST", "/x", { as: t.managerA }), params({ id: t.cashierB.id }));
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { Product } from "@/lib/server/models/Product";
|
||||||
|
import { Purchase } from "@/lib/server/models/Purchase";
|
||||||
|
import { Transaction } from "@/lib/server/models/Transaction";
|
||||||
|
import { backfillPurchaseMovements, stockOf } from "@/lib/server/stock";
|
||||||
|
import * as cash from "@/app/api/transactions/cash/route";
|
||||||
|
import * as transfers from "@/app/api/stock/transfers/route";
|
||||||
|
import * as profit from "@/app/api/reports/profit/route";
|
||||||
|
import * as summary from "@/app/api/dashboard/summary/route";
|
||||||
|
import { json, makeTenant, req, uuid, type Actor } from "./helpers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "Completion and retest plan" from the 11 October 2026 test report
|
||||||
|
* (AquaFlow_Initial_Test_Report, pages 9-10), with its expected numbers.
|
||||||
|
*/
|
||||||
|
describe("initial test report: completion and retest plan", () => {
|
||||||
|
it("matches every planned balance and total", async () => {
|
||||||
|
const t = await makeTenant("ZERRO");
|
||||||
|
const [refill3] = await Product.insertMany([
|
||||||
|
{ businessId: t.businessId, label: "QA 20261011 3L Refill", sizeLiters: 3, unitPrice: 40 },
|
||||||
|
]);
|
||||||
|
const l3 = refill3._id.toString();
|
||||||
|
const l5 = t.water5; // KES 60
|
||||||
|
|
||||||
|
// Fixtures recorded before the ledger existed (QA-20261011-STOCK-A-3L / -5L),
|
||||||
|
// brought into stock by the one-off backfill.
|
||||||
|
await Purchase.insertMany([
|
||||||
|
{ businessId: t.businessId, branchId: t.branchA, supplierName: "QA", productId: l3, productLabel: "3L", quantity: 20, unitCost: 10, totalCost: 200, recordedBy: t.owner.id },
|
||||||
|
{ businessId: t.businessId, branchId: t.branchA, supplierName: "QA", productId: l5, productLabel: "5L", quantity: 10, unitCost: 20, totalCost: 200, recordedBy: t.owner.id },
|
||||||
|
]);
|
||||||
|
await backfillPurchaseMovements(t.businessId);
|
||||||
|
const stock = async (branch: string, product: string) => stockOf(t.businessId, branch, product);
|
||||||
|
expect([await stock(t.branchA, l3), await stock(t.branchA, l5)]).toEqual([20, 10]);
|
||||||
|
|
||||||
|
const sell = async (as: Actor, lines: { productId: string; quantity: number }[], offline = false) =>
|
||||||
|
json(
|
||||||
|
await cash.POST(
|
||||||
|
req("POST", "/x", {
|
||||||
|
as,
|
||||||
|
body: {
|
||||||
|
sales: [
|
||||||
|
{
|
||||||
|
clientUuid: uuid(),
|
||||||
|
staffId: as.id,
|
||||||
|
branchId: as.branchId,
|
||||||
|
lines,
|
||||||
|
...(offline ? { clientSubmittedAt: new Date().toISOString() } : {}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Online sale: 2 x 3L + 1 x 5L = KES 140; A balances 18 and 9.
|
||||||
|
const online = await sell(t.cashierA, [{ productId: l3, quantity: 2 }, { productId: l5, quantity: 1 }]);
|
||||||
|
expect(online.body.results[0]).toMatchObject({ status: "saved", amountTotal: 140 });
|
||||||
|
expect([await stock(t.branchA, l3), await stock(t.branchA, l5)]).toEqual([18, 9]);
|
||||||
|
|
||||||
|
// Transfer 5 x 3L from A to B: A 13, B 5, combined 18.
|
||||||
|
const transfer = await transfers.POST(
|
||||||
|
req("POST", "/x", {
|
||||||
|
as: t.owner,
|
||||||
|
body: { clientUuid: uuid(), fromBranchId: t.branchA, toBranchId: t.branchB, productId: l3, quantity: 5 },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(transfer.status).toBe(201);
|
||||||
|
expect([await stock(t.branchA, l3), await stock(t.branchB, l3)]).toEqual([13, 5]);
|
||||||
|
|
||||||
|
// Two offline sales replayed on reconnect (sale 2: 1 x 3L; sale 3: 2 x 3L + 1 x 5L),
|
||||||
|
// then replayed again: exactly two new sales (three lines), A 10 x 3L and 8 x 5L.
|
||||||
|
const before = await Transaction.countDocuments({ businessId: t.businessId });
|
||||||
|
const queued = [
|
||||||
|
{ clientUuid: uuid(), staffId: t.cashierA.id, branchId: t.branchA, clientSubmittedAt: new Date().toISOString(), lines: [{ productId: l3, quantity: 1 }] },
|
||||||
|
{ clientUuid: uuid(), staffId: t.cashierA.id, branchId: t.branchA, clientSubmittedAt: new Date().toISOString(), lines: [{ productId: l3, quantity: 2 }, { productId: l5, quantity: 1 }] },
|
||||||
|
];
|
||||||
|
for (let pass = 0; pass < 2; pass++) {
|
||||||
|
const r = await json(await cash.POST(req("POST", "/x", { as: t.cashierA, body: { sales: queued } })));
|
||||||
|
expect(r.body.results.map((x: any) => x.status)).toEqual(pass === 0 ? ["saved", "saved"] : ["duplicate", "duplicate"]);
|
||||||
|
}
|
||||||
|
expect((await Transaction.countDocuments({ businessId: t.businessId })) - before).toBe(3);
|
||||||
|
expect([await stock(t.branchA, l3), await stock(t.branchA, l5)]).toEqual([10, 8]);
|
||||||
|
|
||||||
|
// Branch B sells 1 x 3L for KES 40.
|
||||||
|
await sell(t.cashierB, [{ productId: l3, quantity: 1 }]);
|
||||||
|
|
||||||
|
// Final: A 3L 10, B 3L 4, A 5L 8; combined 3L 14 = 20 - 6 sold, 5L 8 = 10 - 2 sold.
|
||||||
|
expect([await stock(t.branchA, l3), await stock(t.branchB, l3), await stock(t.branchA, l5)]).toEqual([10, 4, 8]);
|
||||||
|
|
||||||
|
// Revenue: A KES 320, B KES 40, total KES 360 — on the report and the owner dashboard.
|
||||||
|
const report = async (q: string) => (await json(await profit.GET(req("GET", "/api/reports/profit" + q, { as: t.owner })))).body;
|
||||||
|
expect((await report("")).revenue).toBe(360);
|
||||||
|
expect((await report("?branchId=" + t.branchA)).revenue).toBe(320);
|
||||||
|
expect((await report("?branchId=" + t.branchB)).revenue).toBe(40);
|
||||||
|
|
||||||
|
const dash = (await json(await summary.GET(req("GET", "/api/dashboard/summary", { as: t.owner })))).body;
|
||||||
|
expect(dash.totals.cashTotal).toBe(360);
|
||||||
|
const ranking = Object.fromEntries(dash.branchRanking.map((b: any) => [b.branchName, b.revenue]));
|
||||||
|
expect(ranking).toEqual({ "ZERRO A": 320, "ZERRO B": 40 });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user