diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte
index d9db9bf..a2164e2 100644
--- a/src/lib/components/layout/Sidebar.svelte
+++ b/src/lib/components/layout/Sidebar.svelte
@@ -34,6 +34,16 @@
Dashboard
+
+
+ Feature Requests
+
+
{#if companies.length > 0}
Companies
diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts
index 32c4c73..aa966a3 100644
--- a/src/lib/server/db/schema.ts
+++ b/src/lib/server/db/schema.ts
@@ -609,6 +609,51 @@ export const shippingAccounts = pgTable(
(table) => [uniqueIndex('shipping_accounts_company_carrier_idx').on(table.companyId, table.carrier)]
);
+// ── Feature Requests ───────────────────────────────────
+
+export const featureRequestStatusEnum = pgEnum('feature_request_status', [
+ 'open',
+ 'in_review',
+ 'waiting_for_checks',
+ 'in_progress',
+ 'resolved',
+ 'closed'
+]);
+
+export const featureRequests = pgTable(
+ 'feature_requests',
+ {
+ id: uuid('id').primaryKey().defaultRandom(),
+ title: text('title').notNull(),
+ description: text('description'),
+ status: featureRequestStatusEnum('status').notNull().default('open'),
+ submittedBy: text('submitted_by')
+ .notNull()
+ .references(() => users.id, { onDelete: 'set null' }),
+ statusChangedBy: text('status_changed_by').references(() => users.id, { onDelete: 'set null' }),
+ statusChangedAt: timestamp('status_changed_at', { withTimezone: true }),
+ statusNote: text('status_note'),
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
+ },
+ (table) => [index('feature_requests_status_idx').on(table.status)]
+);
+
+export const featureRequestVotes = pgTable(
+ 'feature_request_votes',
+ {
+ id: uuid('id').primaryKey().defaultRandom(),
+ requestId: uuid('request_id')
+ .notNull()
+ .references(() => featureRequests.id, { onDelete: 'cascade' }),
+ userId: text('user_id')
+ .notNull()
+ .references(() => users.id, { onDelete: 'cascade' }),
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
+ },
+ (table) => [uniqueIndex('feature_request_votes_request_user_idx').on(table.requestId, table.userId)]
+);
+
// ── Company Log (Audit Trail) ──────────────────────────
export const companyLogEventEnum = pgEnum('company_log_event', [
diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts
index d06f701..f679cd6 100644
--- a/src/lib/types/index.ts
+++ b/src/lib/types/index.ts
@@ -7,6 +7,13 @@ export type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'overdue' | 'cancelled';
export type PayslipStatus = 'draft' | 'finalized' | 'paid';
export type TxDirection = 'credit' | 'debit';
export type IntegrationProvider = 'kasikorn_kbiz' | 'etherfi' | 'manual';
+export type FeatureRequestStatus =
+ | 'open'
+ | 'in_review'
+ | 'waiting_for_checks'
+ | 'in_progress'
+ | 'resolved'
+ | 'closed';
/**
* Hierarchical roles — only these ranks. `hr` is orthogonal and excluded.
diff --git a/src/routes/(app)/feature-requests/+page.server.ts b/src/routes/(app)/feature-requests/+page.server.ts
new file mode 100644
index 0000000..5e8629b
--- /dev/null
+++ b/src/routes/(app)/feature-requests/+page.server.ts
@@ -0,0 +1,168 @@
+import { fail } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+import { db } from '$lib/server/db/index.js';
+import { featureRequests, featureRequestVotes, users } from '$lib/server/db/schema.js';
+import { eq, and, sql, desc } from 'drizzle-orm';
+import { requireAuth } from '$lib/server/authorization.js';
+import type { FeatureRequestStatus } from '$lib/types/index.js';
+
+const ALL_STATUSES: FeatureRequestStatus[] = [
+ 'open',
+ 'in_review',
+ 'waiting_for_checks',
+ 'in_progress',
+ 'resolved',
+ 'closed'
+];
+
+export const load: PageServerLoad = async ({ locals, url }) => {
+ const user = requireAuth(locals);
+ const status = url.searchParams.get('status') ?? 'all';
+ const sort = url.searchParams.get('sort') ?? 'votes';
+
+ const baseSelect = db
+ .select({
+ id: featureRequests.id,
+ title: featureRequests.title,
+ description: featureRequests.description,
+ status: featureRequests.status,
+ submittedBy: featureRequests.submittedBy,
+ submitterName: users.displayName,
+ submitterEmail: users.email,
+ statusNote: featureRequests.statusNote,
+ statusChangedAt: featureRequests.statusChangedAt,
+ createdAt: featureRequests.createdAt,
+ voteCount: sql
`(
+ SELECT COUNT(*)::int FROM ${featureRequestVotes}
+ WHERE ${featureRequestVotes.requestId} = ${featureRequests.id}
+ )`,
+ myVote: sql`EXISTS (
+ SELECT 1 FROM ${featureRequestVotes}
+ WHERE ${featureRequestVotes.requestId} = ${featureRequests.id}
+ AND ${featureRequestVotes.userId} = ${user.id}
+ )`
+ })
+ .from(featureRequests)
+ .leftJoin(users, eq(featureRequests.submittedBy, users.id));
+
+ const filtered =
+ status !== 'all' && (ALL_STATUSES as string[]).includes(status)
+ ? baseSelect.where(eq(featureRequests.status, status as FeatureRequestStatus))
+ : baseSelect;
+
+ const ordered =
+ sort === 'newest'
+ ? filtered.orderBy(desc(featureRequests.createdAt))
+ : filtered.orderBy(
+ desc(sql`(SELECT COUNT(*) FROM ${featureRequestVotes} WHERE ${featureRequestVotes.requestId} = ${featureRequests.id})`),
+ desc(featureRequests.createdAt)
+ );
+
+ const requests = await ordered;
+
+ return {
+ requests,
+ filters: { status, sort },
+ isSystemAdmin: user.isSystemAdmin
+ };
+};
+
+export const actions: Actions = {
+ create: async ({ request, locals }) => {
+ const user = requireAuth(locals);
+ const formData = await request.formData();
+ const title = formData.get('title')?.toString().trim();
+ const description = formData.get('description')?.toString().trim() || null;
+
+ if (!title) return fail(400, { error: 'Title is required' });
+ if (title.length > 200) return fail(400, { error: 'Title is too long' });
+
+ const [created] = await db
+ .insert(featureRequests)
+ .values({ title, description, submittedBy: user.id })
+ .returning({ id: featureRequests.id });
+
+ // Auto-upvote by submitter
+ await db.insert(featureRequestVotes).values({ requestId: created.id, userId: user.id });
+
+ return { success: true, createdId: created.id };
+ },
+
+ toggleVote: async ({ request, locals }) => {
+ const user = requireAuth(locals);
+ const formData = await request.formData();
+ const requestId = formData.get('requestId')?.toString();
+ if (!requestId) return fail(400, { error: 'Missing request ID' });
+
+ const existing = await db
+ .select({ id: featureRequestVotes.id })
+ .from(featureRequestVotes)
+ .where(
+ and(
+ eq(featureRequestVotes.requestId, requestId),
+ eq(featureRequestVotes.userId, user.id)
+ )
+ )
+ .limit(1);
+
+ if (existing.length > 0) {
+ await db.delete(featureRequestVotes).where(eq(featureRequestVotes.id, existing[0].id));
+ } else {
+ await db.insert(featureRequestVotes).values({ requestId, userId: user.id });
+ }
+
+ return { success: true };
+ },
+
+ updateStatus: async ({ request, locals }) => {
+ const user = requireAuth(locals);
+ if (!user.isSystemAdmin) {
+ return fail(403, { error: 'Only system admins can change status' });
+ }
+
+ const formData = await request.formData();
+ const requestId = formData.get('requestId')?.toString();
+ const newStatus = formData.get('status')?.toString() as FeatureRequestStatus | undefined;
+ const note = formData.get('statusNote')?.toString().trim() || null;
+
+ if (!requestId) return fail(400, { error: 'Missing request ID' });
+ if (!newStatus || !ALL_STATUSES.includes(newStatus)) {
+ return fail(400, { error: 'Invalid status' });
+ }
+
+ await db
+ .update(featureRequests)
+ .set({
+ status: newStatus,
+ statusChangedBy: user.id,
+ statusChangedAt: new Date(),
+ statusNote: note,
+ updatedAt: new Date()
+ })
+ .where(eq(featureRequests.id, requestId));
+
+ return { success: true };
+ },
+
+ delete: async ({ request, locals }) => {
+ const user = requireAuth(locals);
+ const formData = await request.formData();
+ const requestId = formData.get('requestId')?.toString();
+ if (!requestId) return fail(400, { error: 'Missing request ID' });
+
+ // Allow deletion by system admin OR original submitter
+ const [req] = await db
+ .select({ submittedBy: featureRequests.submittedBy })
+ .from(featureRequests)
+ .where(eq(featureRequests.id, requestId))
+ .limit(1);
+ if (!req) return fail(404, { error: 'Not found' });
+
+ if (!user.isSystemAdmin && req.submittedBy !== user.id) {
+ return fail(403, { error: 'You can only delete your own requests' });
+ }
+
+ await db.delete(featureRequests).where(eq(featureRequests.id, requestId));
+ return { success: true };
+ }
+};
diff --git a/src/routes/(app)/feature-requests/+page.svelte b/src/routes/(app)/feature-requests/+page.svelte
new file mode 100644
index 0000000..e511ce6
--- /dev/null
+++ b/src/routes/(app)/feature-requests/+page.svelte
@@ -0,0 +1,272 @@
+
+
+
+ Feature Requests - {data.appName}
+
+
+
+
+
+
Feature Requests
+
+ Suggest improvements and upvote others' ideas.
+
+
+
+
+
+ {#if form?.error}
+
+ {form.error}
+
+ {/if}
+
+
+
+
+ {#if data.requests.length === 0}
+
+
No feature requests yet.
+
+ {:else}
+
+ {#each data.requests as req}
+ -
+
+
+
+
+
+
+
+
{req.title}
+
+ {STATUS_LABELS[req.status as FeatureRequestStatus]}
+
+
+ {#if req.description}
+
{req.description}
+ {/if}
+ {#if req.statusNote}
+
+ Note from admin: {req.statusNote}
+
+ {/if}
+
+ Submitted by {req.submitterName ?? req.submitterEmail ?? '—'} · {timeAgo(req.createdAt)}
+
+
+
+ {#if data.isSystemAdmin}
+
+ {/if}
+
+
+ {#if data.isSystemAdmin}
+
+ {/if}
+
+
+
+ {/each}
+
+ {/if}
+
+
+
+{#if showCreate}
+
+
+
New Feature Request
+
+
+
+{/if}