Add feature requests page with upvotes and admin status workflow
Validate / validate (push) Successful in 22s
Validate / validate (push) Successful in 22s
- New /feature-requests route accessible to all logged-in users via sidebar nav - feature_requests + feature_request_votes tables (one vote per user per request) - Submit form (modal), upvote toggle, filter by status, sort by votes/newest - System admins can change status (open / in_review / waiting_for_checks / in_progress / resolved / closed) with optional note - Submitter auto-votes their own request on creation - Admin or original submitter can delete a request Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,16 @@
|
|||||||
Dashboard
|
Dashboard
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/feature-requests"
|
||||||
|
class="mb-1 flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||||
|
</svg>
|
||||||
|
Feature Requests
|
||||||
|
</a>
|
||||||
|
|
||||||
{#if companies.length > 0}
|
{#if companies.length > 0}
|
||||||
<div class="mt-4 mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
<div class="mt-4 mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||||
Companies
|
Companies
|
||||||
|
|||||||
@@ -609,6 +609,51 @@ export const shippingAccounts = pgTable(
|
|||||||
(table) => [uniqueIndex('shipping_accounts_company_carrier_idx').on(table.companyId, table.carrier)]
|
(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) ──────────────────────────
|
// ── Company Log (Audit Trail) ──────────────────────────
|
||||||
|
|
||||||
export const companyLogEventEnum = pgEnum('company_log_event', [
|
export const companyLogEventEnum = pgEnum('company_log_event', [
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ export type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'overdue' | 'cancelled';
|
|||||||
export type PayslipStatus = 'draft' | 'finalized' | 'paid';
|
export type PayslipStatus = 'draft' | 'finalized' | 'paid';
|
||||||
export type TxDirection = 'credit' | 'debit';
|
export type TxDirection = 'credit' | 'debit';
|
||||||
export type IntegrationProvider = 'kasikorn_kbiz' | 'etherfi' | 'manual';
|
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.
|
* Hierarchical roles — only these ranks. `hr` is orthogonal and excluded.
|
||||||
|
|||||||
@@ -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<number>`(
|
||||||
|
SELECT COUNT(*)::int FROM ${featureRequestVotes}
|
||||||
|
WHERE ${featureRequestVotes.requestId} = ${featureRequests.id}
|
||||||
|
)`,
|
||||||
|
myVote: sql<boolean>`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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { timeAgo } from '$lib/utils/date.js';
|
||||||
|
import type { PageData, ActionData } from './$types';
|
||||||
|
import type { FeatureRequestStatus } from '$lib/types/index.js';
|
||||||
|
|
||||||
|
let { data, form } = $props<{ data: PageData; form: ActionData }>();
|
||||||
|
|
||||||
|
let showCreate = $state(false);
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<FeatureRequestStatus, string> = {
|
||||||
|
open: 'Open',
|
||||||
|
in_review: 'In Review',
|
||||||
|
waiting_for_checks: 'Waiting for Checks',
|
||||||
|
in_progress: 'In Progress',
|
||||||
|
resolved: 'Resolved',
|
||||||
|
closed: 'Closed'
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_BADGE: Record<FeatureRequestStatus, string> = {
|
||||||
|
open: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||||
|
in_review: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
||||||
|
waiting_for_checks: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
|
||||||
|
in_progress: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300',
|
||||||
|
resolved: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
||||||
|
closed: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
|
||||||
|
};
|
||||||
|
|
||||||
|
const FILTER_PILLS: { value: string; label: string }[] = [
|
||||||
|
{ value: 'all', label: 'All' },
|
||||||
|
{ value: 'open', label: 'Open' },
|
||||||
|
{ value: 'in_review', label: 'In Review' },
|
||||||
|
{ value: 'waiting_for_checks', label: 'Waiting' },
|
||||||
|
{ value: 'in_progress', label: 'In Progress' },
|
||||||
|
{ value: 'resolved', label: 'Resolved' },
|
||||||
|
{ value: 'closed', label: 'Closed' }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Feature Requests - {data.appName}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-4xl">
|
||||||
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Feature Requests</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Suggest improvements and upvote others' ideas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={() => (showCreate = true)}
|
||||||
|
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
+ New Request
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if form?.error}
|
||||||
|
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||||
|
{form.error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Filters + sort -->
|
||||||
|
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each FILTER_PILLS as p}
|
||||||
|
<a
|
||||||
|
href="?status={p.value}&sort={data.filters.sort}"
|
||||||
|
class="rounded-full px-3 py-1 text-xs font-medium transition-colors {data.filters.status === p.value
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}"
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 text-xs">
|
||||||
|
<a
|
||||||
|
href="?status={data.filters.status}&sort=votes"
|
||||||
|
class="rounded px-2 py-1 {data.filters.sort === 'votes'
|
||||||
|
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
|
||||||
|
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'}"
|
||||||
|
>
|
||||||
|
Top voted
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="?status={data.filters.status}&sort=newest"
|
||||||
|
class="rounded px-2 py-1 {data.filters.sort === 'newest'
|
||||||
|
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
|
||||||
|
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'}"
|
||||||
|
>
|
||||||
|
Newest
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if data.requests.length === 0}
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-white p-12 text-center dark:border-gray-700 dark:bg-gray-800">
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">No feature requests yet.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ul class="space-y-3">
|
||||||
|
{#each data.requests as req}
|
||||||
|
<li class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<!-- Vote button -->
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/toggleVote"
|
||||||
|
use:enhance={() => async ({ update }) => {
|
||||||
|
await update({ reset: false });
|
||||||
|
}}
|
||||||
|
class="flex-shrink-0"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="requestId" value={req.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="flex h-14 w-14 flex-col items-center justify-center rounded-md border-2 transition-colors {req.myVote
|
||||||
|
? 'border-blue-500 bg-blue-50 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300'
|
||||||
|
: 'border-gray-200 text-gray-500 hover:border-blue-300 hover:text-blue-600 dark:border-gray-600 dark:text-gray-400 dark:hover:border-blue-700'}"
|
||||||
|
title={req.myVote ? 'Remove vote' : 'Upvote'}
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||||
|
</svg>
|
||||||
|
<span class="mt-0.5 text-sm font-bold">{req.voteCount}</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<h3 class="font-medium text-gray-900 dark:text-white">{req.title}</h3>
|
||||||
|
<span class="flex-shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide {STATUS_BADGE[req.status as FeatureRequestStatus]}">
|
||||||
|
{STATUS_LABELS[req.status as FeatureRequestStatus]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if req.description}
|
||||||
|
<p class="mt-1 text-sm text-gray-600 whitespace-pre-line dark:text-gray-300">{req.description}</p>
|
||||||
|
{/if}
|
||||||
|
{#if req.statusNote}
|
||||||
|
<p class="mt-2 rounded-md bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-gray-900/50 dark:text-gray-400">
|
||||||
|
<span class="font-semibold">Note from admin:</span> {req.statusNote}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<p class="mt-2 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
Submitted by {req.submitterName ?? req.submitterEmail ?? '—'} · {timeAgo(req.createdAt)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Admin status controls -->
|
||||||
|
{#if data.isSystemAdmin}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/updateStatus"
|
||||||
|
use:enhance={() => async ({ update }) => {
|
||||||
|
await update({ reset: false });
|
||||||
|
}}
|
||||||
|
class="mt-3 flex flex-wrap items-center gap-2 rounded-md border border-gray-200 p-2 text-xs dark:border-gray-700"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="requestId" value={req.id} />
|
||||||
|
<label class="flex items-center gap-1 text-gray-600 dark:text-gray-400">
|
||||||
|
Status:
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
class="rounded border border-gray-300 px-2 py-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
>
|
||||||
|
{#each Object.entries(STATUS_LABELS) as [val, label]}
|
||||||
|
<option value={val} selected={req.status === val}>{label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="statusNote"
|
||||||
|
placeholder="Optional note (e.g. ETA, blocker)"
|
||||||
|
value={req.statusNote ?? ''}
|
||||||
|
class="flex-1 min-w-32 rounded border border-gray-300 px-2 py-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="rounded bg-blue-600 px-2 py-1 text-xs font-medium text-white hover:bg-blue-700">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Delete (admin can delete any) -->
|
||||||
|
{#if data.isSystemAdmin}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/delete"
|
||||||
|
use:enhance={({ cancel }) => {
|
||||||
|
if (!confirm('Delete this feature request?')) {
|
||||||
|
cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return async ({ update }) => {
|
||||||
|
await update({ reset: false });
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="mt-2"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="requestId" value={req.id} />
|
||||||
|
<button type="submit" class="text-xs text-red-600 hover:text-red-800 dark:text-red-400">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create modal -->
|
||||||
|
{#if showCreate}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div class="w-full max-w-lg rounded-lg bg-white p-6 shadow-xl dark:bg-gray-800">
|
||||||
|
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">New Feature Request</h2>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/create"
|
||||||
|
use:enhance={() => async ({ update }) => {
|
||||||
|
await update();
|
||||||
|
showCreate = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="title" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Title <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
required
|
||||||
|
maxlength="200"
|
||||||
|
placeholder="Short, descriptive summary"
|
||||||
|
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="description" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
rows="5"
|
||||||
|
placeholder="What problem does this solve? What would the ideal experience look like?"
|
||||||
|
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (showCreate = false)}
|
||||||
|
class="rounded-md px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
Reference in New Issue
Block a user