feat(deploy): configure cloudflare workers + D1 + feedback
- configure wrangler for CF account with D1 binding - add feedback API route with rate limiting and github issue creation - add feedback widget component - add project detail page with status/schedule/info tabs - add frappe-gantt type declarations - fix type errors for production build - add migration 0004 for feedback table
This commit is contained in:
parent
d18c341352
commit
41fdfd9e4c
12
cloudflare-env.d.ts
vendored
12
cloudflare-env.d.ts
vendored
@ -1,21 +1,15 @@
|
|||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
// Generated by Wrangler by running `wrangler types --env-interface CloudflareEnv ./cloudflare-env.d.ts` (hash: 6247394513f9e5a236d8a8ed9914d756)
|
// Generated by Wrangler by running `wrangler types --env-interface CloudflareEnv ./cloudflare-env.d.ts` (hash: 17faa1ab93062fdc4d6b4055bea03b00)
|
||||||
// Runtime types generated with workerd@1.20260116.0 2025-12-01 global_fetch_strictly_public,nodejs_compat
|
// Runtime types generated with workerd@1.20260116.0 2025-12-01 global_fetch_strictly_public,nodejs_compat
|
||||||
declare namespace Cloudflare {
|
declare namespace Cloudflare {
|
||||||
interface Env {
|
interface Env {
|
||||||
NEXTJS_ENV: string;
|
DB: D1Database;
|
||||||
WORKER_SELF_REFERENCE: Fetcher /* dashboard-app-template */;
|
WORKER_SELF_REFERENCE: Fetcher /* compass */;
|
||||||
IMAGES: ImagesBinding;
|
IMAGES: ImagesBinding;
|
||||||
ASSETS: Fetcher;
|
ASSETS: Fetcher;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
interface CloudflareEnv extends Cloudflare.Env {}
|
interface CloudflareEnv extends Cloudflare.Env {}
|
||||||
type StringifyValues<EnvType extends Record<string, unknown>> = {
|
|
||||||
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
|
|
||||||
};
|
|
||||||
declare namespace NodeJS {
|
|
||||||
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "NEXTJS_ENV">> {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Begin runtime types
|
// Begin runtime types
|
||||||
/*! *****************************************************************************
|
/*! *****************************************************************************
|
||||||
|
|||||||
14
drizzle/0004_quick_firebrand.sql
Executable file
14
drizzle/0004_quick_firebrand.sql
Executable file
@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE `feedback` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`type` text NOT NULL,
|
||||||
|
`message` text NOT NULL,
|
||||||
|
`name` text,
|
||||||
|
`email` text,
|
||||||
|
`page_url` text,
|
||||||
|
`user_agent` text,
|
||||||
|
`viewport_width` integer,
|
||||||
|
`viewport_height` integer,
|
||||||
|
`ip_hash` text,
|
||||||
|
`github_issue_url` text,
|
||||||
|
`created_at` text NOT NULL
|
||||||
|
);
|
||||||
648
drizzle/meta/0004_snapshot.json
Executable file
648
drizzle/meta/0004_snapshot.json
Executable file
@ -0,0 +1,648 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "b6fe056d-1639-42cd-9360-80d10f7ebb3a",
|
||||||
|
"prevId": "20ead1c6-feea-44d0-8b20-6f4a5c6989ab",
|
||||||
|
"tables": {
|
||||||
|
"customers": {
|
||||||
|
"name": "customers",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"phone": {
|
||||||
|
"name": "phone",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"feedback": {
|
||||||
|
"name": "feedback",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"name": "message",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"page_url": {
|
||||||
|
"name": "page_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_agent": {
|
||||||
|
"name": "user_agent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"viewport_width": {
|
||||||
|
"name": "viewport_width",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"viewport_height": {
|
||||||
|
"name": "viewport_height",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"ip_hash": {
|
||||||
|
"name": "ip_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"github_issue_url": {
|
||||||
|
"name": "github_issue_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"projects": {
|
||||||
|
"name": "projects",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'OPEN'"
|
||||||
|
},
|
||||||
|
"address": {
|
||||||
|
"name": "address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"client_name": {
|
||||||
|
"name": "client_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"project_manager": {
|
||||||
|
"name": "project_manager",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"schedule_baselines": {
|
||||||
|
"name": "schedule_baselines",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"project_id": {
|
||||||
|
"name": "project_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"snapshot_data": {
|
||||||
|
"name": "snapshot_data",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"schedule_baselines_project_id_projects_id_fk": {
|
||||||
|
"name": "schedule_baselines_project_id_projects_id_fk",
|
||||||
|
"tableFrom": "schedule_baselines",
|
||||||
|
"tableTo": "projects",
|
||||||
|
"columnsFrom": [
|
||||||
|
"project_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"schedule_tasks": {
|
||||||
|
"name": "schedule_tasks",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"project_id": {
|
||||||
|
"name": "project_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"start_date": {
|
||||||
|
"name": "start_date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"workdays": {
|
||||||
|
"name": "workdays",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"end_date_calculated": {
|
||||||
|
"name": "end_date_calculated",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"phase": {
|
||||||
|
"name": "phase",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'PENDING'"
|
||||||
|
},
|
||||||
|
"is_critical_path": {
|
||||||
|
"name": "is_critical_path",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"is_milestone": {
|
||||||
|
"name": "is_milestone",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"percent_complete": {
|
||||||
|
"name": "percent_complete",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"assigned_to": {
|
||||||
|
"name": "assigned_to",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"sort_order": {
|
||||||
|
"name": "sort_order",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"schedule_tasks_project_id_projects_id_fk": {
|
||||||
|
"name": "schedule_tasks_project_id_projects_id_fk",
|
||||||
|
"tableFrom": "schedule_tasks",
|
||||||
|
"tableTo": "projects",
|
||||||
|
"columnsFrom": [
|
||||||
|
"project_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"task_dependencies": {
|
||||||
|
"name": "task_dependencies",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"predecessor_id": {
|
||||||
|
"name": "predecessor_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"successor_id": {
|
||||||
|
"name": "successor_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'FS'"
|
||||||
|
},
|
||||||
|
"lag_days": {
|
||||||
|
"name": "lag_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"task_dependencies_predecessor_id_schedule_tasks_id_fk": {
|
||||||
|
"name": "task_dependencies_predecessor_id_schedule_tasks_id_fk",
|
||||||
|
"tableFrom": "task_dependencies",
|
||||||
|
"tableTo": "schedule_tasks",
|
||||||
|
"columnsFrom": [
|
||||||
|
"predecessor_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"task_dependencies_successor_id_schedule_tasks_id_fk": {
|
||||||
|
"name": "task_dependencies_successor_id_schedule_tasks_id_fk",
|
||||||
|
"tableFrom": "task_dependencies",
|
||||||
|
"tableTo": "schedule_tasks",
|
||||||
|
"columnsFrom": [
|
||||||
|
"successor_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"vendors": {
|
||||||
|
"name": "vendors",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"name": "category",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'Subcontractor'"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"phone": {
|
||||||
|
"name": "phone",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"address": {
|
||||||
|
"name": "address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"workday_exceptions": {
|
||||||
|
"name": "workday_exceptions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"project_id": {
|
||||||
|
"name": "project_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"start_date": {
|
||||||
|
"name": "start_date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"end_date": {
|
||||||
|
"name": "end_date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'non_working'"
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"name": "category",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'company_holiday'"
|
||||||
|
},
|
||||||
|
"recurrence": {
|
||||||
|
"name": "recurrence",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'one_time'"
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"name": "notes",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"workday_exceptions_project_id_projects_id_fk": {
|
||||||
|
"name": "workday_exceptions_project_id_projects_id_fk",
|
||||||
|
"tableFrom": "workday_exceptions",
|
||||||
|
"tableTo": "projects",
|
||||||
|
"columnsFrom": [
|
||||||
|
"project_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -29,6 +29,13 @@
|
|||||||
"when": 1769286212480,
|
"when": 1769286212480,
|
||||||
"tag": "0003_burly_kabuki",
|
"tag": "0003_burly_kabuki",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1769287376759,
|
||||||
|
"tag": "0004_quick_firebrand",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "dashboard-app-template",
|
"name": "compass",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
23
src/app/actions/projects.ts
Executable file
23
src/app/actions/projects.ts
Executable file
@ -0,0 +1,23 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import { getCloudflareContext } from "@opennextjs/cloudflare"
|
||||||
|
import { getDb } from "@/db"
|
||||||
|
import { projects } from "@/db/schema"
|
||||||
|
import { asc } from "drizzle-orm"
|
||||||
|
|
||||||
|
export async function getProjects(): Promise<{ id: string; name: string }[]> {
|
||||||
|
try {
|
||||||
|
const { env } = await getCloudflareContext()
|
||||||
|
if (!env?.DB) return []
|
||||||
|
|
||||||
|
const db = getDb(env.DB)
|
||||||
|
const allProjects = await db
|
||||||
|
.select({ id: projects.id, name: projects.name })
|
||||||
|
.from(projects)
|
||||||
|
.orderBy(asc(projects.name))
|
||||||
|
|
||||||
|
return allProjects
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
178
src/app/api/feedback/route.ts
Executable file
178
src/app/api/feedback/route.ts
Executable file
@ -0,0 +1,178 @@
|
|||||||
|
import { getCloudflareContext } from "@opennextjs/cloudflare"
|
||||||
|
import { drizzle } from "drizzle-orm/d1"
|
||||||
|
import { feedback } from "@/db/schema"
|
||||||
|
import { sql } from "drizzle-orm"
|
||||||
|
|
||||||
|
const FEEDBACK_TYPES = ["bug", "feature", "question", "general"] as const
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const body = await request.json().catch(() => null) as Record<string, unknown> | null
|
||||||
|
if (!body) {
|
||||||
|
return Response.json({ error: "Invalid JSON" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type, message, name, email, pageUrl, userAgent, viewportWidth, viewportHeight } = body as {
|
||||||
|
type: string
|
||||||
|
message: string
|
||||||
|
name?: string
|
||||||
|
email?: string
|
||||||
|
pageUrl?: string
|
||||||
|
userAgent?: string
|
||||||
|
viewportWidth?: number
|
||||||
|
viewportHeight?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(FEEDBACK_TYPES as readonly string[]).includes(type)) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Invalid type. Must be: bug, feature, question, or general" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!message || typeof message !== "string" || message.trim().length === 0) {
|
||||||
|
return Response.json({ error: "Message is required" }, { status: 400 })
|
||||||
|
}
|
||||||
|
if (message.length > 2000) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Message must be 2000 characters or less" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { env, cf } = await getCloudflareContext()
|
||||||
|
const db = drizzle(env.DB)
|
||||||
|
|
||||||
|
const ip = (cf as { request?: Request })?.request?.headers?.get("cf-connecting-ip")
|
||||||
|
?? request.headers.get("cf-connecting-ip")
|
||||||
|
?? "unknown"
|
||||||
|
const ipHash = await hashIp(ip)
|
||||||
|
|
||||||
|
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString()
|
||||||
|
const recentSubmissions = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(feedback)
|
||||||
|
.where(
|
||||||
|
sql`${feedback.ipHash} = ${ipHash} AND ${feedback.createdAt} > ${oneHourAgo}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (recentSubmissions[0].count >= 5) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Too many submissions. Please try again later." },
|
||||||
|
{ status: 429 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = crypto.randomUUID()
|
||||||
|
const createdAt = new Date().toISOString()
|
||||||
|
|
||||||
|
await db.insert(feedback).values({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
message: message.trim(),
|
||||||
|
name: name?.trim() || null,
|
||||||
|
email: email?.trim() || null,
|
||||||
|
pageUrl: pageUrl || null,
|
||||||
|
userAgent: userAgent || null,
|
||||||
|
viewportWidth: viewportWidth || null,
|
||||||
|
viewportHeight: viewportHeight || null,
|
||||||
|
ipHash,
|
||||||
|
createdAt,
|
||||||
|
})
|
||||||
|
|
||||||
|
createGithubIssue(env, db, id, {
|
||||||
|
type,
|
||||||
|
message: message.trim(),
|
||||||
|
name: name?.trim(),
|
||||||
|
email: email?.trim(),
|
||||||
|
pageUrl,
|
||||||
|
userAgent,
|
||||||
|
viewportWidth,
|
||||||
|
viewportHeight,
|
||||||
|
createdAt,
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response.json({ success: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hashIp(ip: string): Promise<string> {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const data = encoder.encode(ip)
|
||||||
|
const hashBuffer = await crypto.subtle.digest("SHA-256", data)
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||||
|
return hashArray.map(b => b.toString(16).padStart(2, "0")).join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABEL_MAP: Record<string, string> = {
|
||||||
|
bug: "bug",
|
||||||
|
feature: "enhancement",
|
||||||
|
question: "question",
|
||||||
|
general: "feedback",
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createGithubIssue(
|
||||||
|
env: CloudflareEnv,
|
||||||
|
db: ReturnType<typeof drizzle>,
|
||||||
|
feedbackId: string,
|
||||||
|
data: {
|
||||||
|
type: string
|
||||||
|
message: string
|
||||||
|
name?: string
|
||||||
|
email?: string
|
||||||
|
pageUrl?: string
|
||||||
|
userAgent?: string
|
||||||
|
viewportWidth?: number
|
||||||
|
viewportHeight?: number
|
||||||
|
createdAt: string
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const token = (env as unknown as Record<string, unknown>).GITHUB_TOKEN as string | undefined
|
||||||
|
?? process.env.GITHUB_TOKEN
|
||||||
|
const repo = (env as unknown as Record<string, unknown>).GITHUB_REPO as string | undefined
|
||||||
|
?? process.env.GITHUB_REPO
|
||||||
|
if (!token || !repo) return
|
||||||
|
|
||||||
|
const titlePrefix = `[${data.type}]`
|
||||||
|
const titleMessage = data.message.slice(0, 60) + (data.message.length > 60 ? "..." : "")
|
||||||
|
const title = `${titlePrefix} ${titleMessage}`
|
||||||
|
|
||||||
|
const fromLine = data.name
|
||||||
|
? `${data.name}${data.email ? ` (${data.email})` : ""}`
|
||||||
|
: `Anonymous${data.email ? ` (${data.email})` : ""}`
|
||||||
|
|
||||||
|
const body = `## Feedback: ${data.type}
|
||||||
|
|
||||||
|
${data.message}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**From:** ${fromLine}
|
||||||
|
**Page:** ${data.pageUrl || "Unknown"}
|
||||||
|
**Viewport:** ${data.viewportWidth || "?"}x${data.viewportHeight || "?"}
|
||||||
|
**User Agent:** ${data.userAgent || "Unknown"}
|
||||||
|
**Timestamp:** ${data.createdAt}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://api.github.com/repos/${repo}/issues`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "compass-feedback-widget",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
labels: [LABEL_MAP[data.type] || "feedback"],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const issue = await res.json() as { html_url: string }
|
||||||
|
await db
|
||||||
|
.update(feedback)
|
||||||
|
.set({ githubIssueUrl: issue.html_url })
|
||||||
|
.where(sql`${feedback.id} = ${feedbackId}`)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// non-blocking: don't fail the feedback submission
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,5 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Toaster } from "sonner"
|
|
||||||
import { FilesProvider } from "@/hooks/use-files"
|
import { FilesProvider } from "@/hooks/use-files"
|
||||||
|
|
||||||
export default function FilesLayout({
|
export default function FilesLayout({
|
||||||
@ -11,7 +10,6 @@ export default function FilesLayout({
|
|||||||
return (
|
return (
|
||||||
<FilesProvider>
|
<FilesProvider>
|
||||||
{children}
|
{children}
|
||||||
<Toaster position="bottom-right" />
|
|
||||||
</FilesProvider>
|
</FilesProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import { AppSidebar } from "@/components/app-sidebar"
|
|||||||
import { SiteHeader } from "@/components/site-header"
|
import { SiteHeader } from "@/components/site-header"
|
||||||
import { CommandMenuProvider } from "@/components/command-menu-provider"
|
import { CommandMenuProvider } from "@/components/command-menu-provider"
|
||||||
import { SettingsProvider } from "@/components/settings-provider"
|
import { SettingsProvider } from "@/components/settings-provider"
|
||||||
|
import { FeedbackWidget } from "@/components/feedback-widget"
|
||||||
|
import { Toaster } from "@/components/ui/sonner"
|
||||||
import {
|
import {
|
||||||
SidebarInset,
|
SidebarInset,
|
||||||
SidebarProvider,
|
SidebarProvider,
|
||||||
@ -28,6 +30,7 @@ export default async function DashboardLayout({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<AppSidebar variant="inset" projects={projectList} />
|
<AppSidebar variant="inset" projects={projectList} />
|
||||||
|
<FeedbackWidget>
|
||||||
<SidebarInset className="overflow-hidden">
|
<SidebarInset className="overflow-hidden">
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||||
@ -36,9 +39,11 @@ export default async function DashboardLayout({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
|
</FeedbackWidget>
|
||||||
<p className="pointer-events-none fixed bottom-3 left-0 right-0 text-center text-xs text-muted-foreground/60">
|
<p className="pointer-events-none fixed bottom-3 left-0 right-0 text-center text-xs text-muted-foreground/60">
|
||||||
Pre-alpha build
|
Pre-alpha build
|
||||||
</p>
|
</p>
|
||||||
|
<Toaster position="bottom-right" />
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</CommandMenuProvider>
|
</CommandMenuProvider>
|
||||||
</SettingsProvider>
|
</SettingsProvider>
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { FeedbackCallout } from "@/components/feedback-widget"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
IconBrandGithub,
|
IconBrandGithub,
|
||||||
@ -93,6 +94,9 @@ export default async function Page() {
|
|||||||
Development preview — features may be incomplete
|
Development preview — features may be incomplete
|
||||||
or change without notice.
|
or change without notice.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
<FeedbackCallout />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-10 lg:grid-cols-2">
|
<div className="grid gap-10 lg:grid-cols-2">
|
||||||
|
|||||||
478
src/app/dashboard/projects/[id]/page.tsx
Executable file
478
src/app/dashboard/projects/[id]/page.tsx
Executable file
@ -0,0 +1,478 @@
|
|||||||
|
import { getCloudflareContext } from "@opennextjs/cloudflare"
|
||||||
|
import { getDb } from "@/db"
|
||||||
|
import { projects, scheduleTasks } from "@/db/schema"
|
||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
import Link from "next/link"
|
||||||
|
import {
|
||||||
|
IconAlertTriangle,
|
||||||
|
IconCalendarStats,
|
||||||
|
IconCheck,
|
||||||
|
IconClock,
|
||||||
|
IconDots,
|
||||||
|
IconFlag,
|
||||||
|
IconPlus,
|
||||||
|
IconThumbUp,
|
||||||
|
IconUser,
|
||||||
|
} from "@tabler/icons-react"
|
||||||
|
import type { ScheduleTask } from "@/db/schema"
|
||||||
|
|
||||||
|
function getWeekDays(): { date: Date; dayName: string }[] {
|
||||||
|
const today = new Date()
|
||||||
|
const day = today.getDay()
|
||||||
|
const monday = new Date(today)
|
||||||
|
monday.setDate(today.getDate() - (day === 0 ? 6 : day - 1))
|
||||||
|
|
||||||
|
const days = []
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const d = new Date(monday)
|
||||||
|
d.setDate(monday.getDate() + i)
|
||||||
|
days.push({
|
||||||
|
date: d,
|
||||||
|
dayName: d.toLocaleDateString("en-US", { weekday: "long" }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateStr(d: Date): string {
|
||||||
|
return d.toISOString().split("T")[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTaskOnDate(task: ScheduleTask, dateStr: string): boolean {
|
||||||
|
return task.startDate <= dateStr && task.endDateCalculated >= dateStr
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProjectSummaryPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}) {
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
let project: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
status: string
|
||||||
|
address: string | null
|
||||||
|
clientName: string | null
|
||||||
|
projectManager: string | null
|
||||||
|
createdAt: string
|
||||||
|
} | null = null
|
||||||
|
let tasks: ScheduleTask[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { env } = await getCloudflareContext()
|
||||||
|
if (!env?.DB) throw new Error("D1 not available")
|
||||||
|
|
||||||
|
const db = getDb(env.DB)
|
||||||
|
|
||||||
|
const [found] = await db
|
||||||
|
.select()
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, id))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!found) notFound()
|
||||||
|
project = found
|
||||||
|
|
||||||
|
tasks = await db
|
||||||
|
.select()
|
||||||
|
.from(scheduleTasks)
|
||||||
|
.where(eq(scheduleTasks.projectId, id))
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.digest === "NEXT_NOT_FOUND") throw e
|
||||||
|
console.warn("D1 unavailable in dev mode, using empty data")
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectName = project?.name ?? "Project"
|
||||||
|
const projectStatus = project?.status ?? "OPEN"
|
||||||
|
const todayStr = formatDateStr(new Date())
|
||||||
|
|
||||||
|
const completedTasks = tasks.filter((t) => t.status === "COMPLETE")
|
||||||
|
const activeTasks = tasks.filter((t) => t.status !== "COMPLETE")
|
||||||
|
const totalCount = tasks.length
|
||||||
|
const completedPercent = totalCount > 0
|
||||||
|
? Math.round((completedTasks.length / totalCount) * 100)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const pastDue = activeTasks.filter(
|
||||||
|
(t) => t.endDateCalculated < todayStr
|
||||||
|
)
|
||||||
|
const dueToday = activeTasks.filter(
|
||||||
|
(t) => t.endDateCalculated === todayStr
|
||||||
|
)
|
||||||
|
const upcomingMilestones = tasks.filter(
|
||||||
|
(t) => t.isMilestone && t.startDate >= todayStr && t.status !== "COMPLETE"
|
||||||
|
)
|
||||||
|
|
||||||
|
// phase breakdown
|
||||||
|
const phases = new Map<string, { total: number; completed: number }>()
|
||||||
|
for (const t of tasks) {
|
||||||
|
const entry = phases.get(t.phase) ?? { total: 0, completed: 0 }
|
||||||
|
entry.total++
|
||||||
|
if (t.status === "COMPLETE") entry.completed++
|
||||||
|
phases.set(t.phase, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recent updates (tasks sorted by updatedAt desc)
|
||||||
|
const recentUpdates = [...tasks]
|
||||||
|
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||||
|
.slice(0, 5)
|
||||||
|
|
||||||
|
// week agenda
|
||||||
|
const weekDays = getWeekDays()
|
||||||
|
const weekAgenda = weekDays.map((day) => {
|
||||||
|
const dateStr = formatDateStr(day.date)
|
||||||
|
const dayTasks = tasks.filter((t) => isTaskOnDate(t, dateStr))
|
||||||
|
const isToday = dateStr === todayStr
|
||||||
|
const isWeekend = day.date.getDay() === 0 || day.date.getDay() === 6
|
||||||
|
return { ...day, dateStr, dayTasks, isToday, isWeekend }
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{/* header */}
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<h1 className="text-2xl font-semibold">{projectName}</h1>
|
||||||
|
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-primary/10 text-primary">
|
||||||
|
{projectStatus}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{project?.address && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{project.address}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
{totalCount} tasks · {completedPercent}% complete
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button className="p-1 rounded hover:bg-accent transition-colors text-muted-foreground">
|
||||||
|
<IconDots className="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* client / pm row */}
|
||||||
|
<div className="flex gap-8 mb-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium uppercase text-muted-foreground mb-2">
|
||||||
|
Client
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{project?.clientName ? (
|
||||||
|
<>
|
||||||
|
<div className="size-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-medium text-primary">
|
||||||
|
{project.clientName.split(" ").map(w => w[0]).join("").slice(0, 2)}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm">{project.clientName}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button className="size-8 rounded-full border border-dashed flex items-center justify-center text-muted-foreground hover:border-foreground hover:text-foreground transition-colors">
|
||||||
|
<IconPlus className="size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium uppercase text-muted-foreground mb-2">
|
||||||
|
Project Manager
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{project?.projectManager ? (
|
||||||
|
<>
|
||||||
|
<div className="size-8 rounded-full bg-accent flex items-center justify-center">
|
||||||
|
<IconUser className="size-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm">{project.projectManager}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button className="size-8 rounded-full border border-dashed flex items-center justify-center text-muted-foreground hover:border-foreground hover:text-foreground transition-colors">
|
||||||
|
<IconPlus className="size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto self-end">
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/projects/${id}/schedule`}
|
||||||
|
className="text-sm text-primary hover:underline flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<IconCalendarStats className="size-4" />
|
||||||
|
View schedule
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* progress bar */}
|
||||||
|
<div className="rounded-lg border p-4 mb-6">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<p className="text-sm font-medium">Overall Progress</p>
|
||||||
|
<p className="text-sm font-semibold">{completedPercent}%</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary transition-all"
|
||||||
|
style={{ width: `${completedPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 mt-3 text-xs text-muted-foreground">
|
||||||
|
<span>{completedTasks.length} completed</span>
|
||||||
|
<span>{activeTasks.length} in progress</span>
|
||||||
|
{pastDue.length > 0 && (
|
||||||
|
<span className="text-destructive">{pastDue.length} overdue</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* urgency columns */}
|
||||||
|
<div className="grid grid-cols-3 gap-px rounded-lg border overflow-hidden mb-6">
|
||||||
|
<div className="p-4 bg-background">
|
||||||
|
<p className="text-xs font-medium uppercase text-muted-foreground mb-3">
|
||||||
|
Past Due
|
||||||
|
</p>
|
||||||
|
{pastDue.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{pastDue.slice(0, 4).map((t) => (
|
||||||
|
<div key={t.id} className="flex items-center justify-between">
|
||||||
|
<span className="text-sm truncate mr-2">{t.title}</span>
|
||||||
|
<span className="text-xs text-destructive font-medium shrink-0">
|
||||||
|
{new Date(t.endDateCalculated).toLocaleDateString(
|
||||||
|
"en-US", { month: "short", day: "numeric" }
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{pastDue.length > 4 && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
+{pastDue.length - 4} more
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<IconThumbUp className="size-4" />
|
||||||
|
Nothing past due
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-background border-x">
|
||||||
|
<p className="text-xs font-medium uppercase text-muted-foreground mb-3">
|
||||||
|
Due Today
|
||||||
|
</p>
|
||||||
|
{dueToday.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{dueToday.map((t) => (
|
||||||
|
<div key={t.id} className="text-sm truncate">{t.title}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<IconThumbUp className="size-4" />
|
||||||
|
Nothing due today
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-background">
|
||||||
|
<p className="text-xs font-medium uppercase text-muted-foreground mb-3">
|
||||||
|
Upcoming Milestones
|
||||||
|
</p>
|
||||||
|
{upcomingMilestones.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{upcomingMilestones.slice(0, 4).map((t) => (
|
||||||
|
<div key={t.id} className="flex items-center justify-between">
|
||||||
|
<span className="text-sm truncate mr-2">{t.title}</span>
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">
|
||||||
|
{new Date(t.startDate).toLocaleDateString(
|
||||||
|
"en-US", { month: "short", day: "numeric" }
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<IconFlag className="size-4" />
|
||||||
|
No upcoming milestones
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* two-column: phases + active tasks */}
|
||||||
|
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||||
|
{/* phase breakdown */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-medium uppercase text-muted-foreground mb-3">
|
||||||
|
Phases
|
||||||
|
</h2>
|
||||||
|
{phases.size > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[...phases.entries()].map(([phase, data]) => {
|
||||||
|
const pct = Math.round((data.completed / data.total) * 100)
|
||||||
|
return (
|
||||||
|
<div key={phase}>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-sm capitalize">{phase}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{data.completed}/{data.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-1.5 rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary/70"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No phases yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* active tasks */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-medium uppercase text-muted-foreground mb-3">
|
||||||
|
Active Tasks
|
||||||
|
</h2>
|
||||||
|
{activeTasks.length > 0 ? (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{activeTasks
|
||||||
|
.sort((a, b) => a.startDate.localeCompare(b.startDate))
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((t) => (
|
||||||
|
<div key={t.id} className="flex items-center gap-2 text-sm">
|
||||||
|
{t.endDateCalculated < todayStr ? (
|
||||||
|
<IconAlertTriangle className="size-3.5 text-destructive shrink-0" />
|
||||||
|
) : (
|
||||||
|
<IconClock className="size-3.5 text-muted-foreground shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="truncate flex-1">{t.title}</span>
|
||||||
|
<div className="w-12 h-1.5 rounded-full bg-muted shrink-0">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-foreground/50"
|
||||||
|
style={{ width: `${t.percentComplete}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{activeTasks.length > 8 && (
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/projects/${id}/schedule`}
|
||||||
|
className="text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
+{activeTasks.length - 8} more in schedule
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<IconCheck className="size-4" />
|
||||||
|
All tasks complete
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* recent updates */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-medium uppercase text-muted-foreground mb-3">
|
||||||
|
Recent Updates
|
||||||
|
</h2>
|
||||||
|
{recentUpdates.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{recentUpdates.map((t) => (
|
||||||
|
<div key={t.id} className="flex items-start gap-3">
|
||||||
|
<div className="size-7 rounded-full bg-muted flex items-center justify-center mt-0.5 shrink-0">
|
||||||
|
{t.status === "COMPLETE" ? (
|
||||||
|
<IconCheck className="size-3.5 text-primary" />
|
||||||
|
) : (
|
||||||
|
<IconClock className="size-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm truncate">{t.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t.status === "COMPLETE" ? "Completed" : `${t.percentComplete}% complete`}
|
||||||
|
{" \u00b7 "}
|
||||||
|
{t.phase}
|
||||||
|
{" \u00b7 "}
|
||||||
|
{new Date(t.updatedAt).toLocaleDateString(
|
||||||
|
"en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No recent activity.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* right sidebar: week agenda */}
|
||||||
|
<div className="w-72 border-l overflow-y-auto p-4 shrink-0 hidden lg:block">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-xs font-medium uppercase text-muted-foreground">
|
||||||
|
This Week
|
||||||
|
</h2>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/projects/${id}/schedule`}
|
||||||
|
className="text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
View schedule
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{weekAgenda.map((day) => (
|
||||||
|
<div
|
||||||
|
key={day.dateStr}
|
||||||
|
className={`flex gap-3 rounded-md p-2 ${
|
||||||
|
day.isToday ? "bg-accent" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-center shrink-0 w-10">
|
||||||
|
<p className={`text-lg font-semibold leading-none ${
|
||||||
|
day.isToday ? "text-primary" : ""
|
||||||
|
}`}>
|
||||||
|
{day.date.getDate()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium">{day.dayName}</p>
|
||||||
|
{day.isWeekend ? (
|
||||||
|
<p className="text-xs text-muted-foreground">Non-workday</p>
|
||||||
|
) : day.dayTasks.length > 0 ? (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{day.dayTasks.slice(0, 3).map((t) => (
|
||||||
|
<p key={t.id} className="text-xs text-muted-foreground truncate">
|
||||||
|
{t.title}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
{day.dayTasks.length > 3 && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
+{day.dayTasks.length - 3} more
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">No tasks</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -43,8 +43,8 @@ export default async function SchedulePage({
|
|||||||
getSchedule(id),
|
getSchedule(id),
|
||||||
getBaselines(id),
|
getBaselines(id),
|
||||||
])
|
])
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
if (e?.digest === "NEXT_NOT_FOUND") throw e
|
if (e && typeof e === "object" && "digest" in e && e.digest === "NEXT_NOT_FOUND") throw e
|
||||||
console.warn("D1 unavailable in dev mode, using empty data")
|
console.warn("D1 unavailable in dev mode, using empty data")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
182
src/components/feedback-widget.tsx
Executable file
182
src/components/feedback-widget.tsx
Executable file
@ -0,0 +1,182 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { createContext, useContext, useState } from "react"
|
||||||
|
import { usePathname } from "next/navigation"
|
||||||
|
import { MessageCircle } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
||||||
|
const FeedbackContext = createContext<{ open: () => void }>({ open: () => {} })
|
||||||
|
|
||||||
|
export function useFeedback() {
|
||||||
|
return useContext(FeedbackContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeedbackCallout() {
|
||||||
|
const { open } = useFeedback()
|
||||||
|
return (
|
||||||
|
<p className="text-primary font-semibold">
|
||||||
|
Have feedback?{" "}
|
||||||
|
<button onClick={open} className="underline underline-offset-2 hover:opacity-80">
|
||||||
|
Let us know what you think
|
||||||
|
</button>
|
||||||
|
{" "}— we'd love to hear from you.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeedbackWidget({ children }: { children?: React.ReactNode }) {
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [type, setType] = useState<string>("")
|
||||||
|
const [message, setMessage] = useState("")
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [email, setEmail] = useState("")
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
setType("")
|
||||||
|
setMessage("")
|
||||||
|
setName("")
|
||||||
|
setEmail("")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!type || !message.trim()) return
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/feedback", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
type,
|
||||||
|
message,
|
||||||
|
name: name || undefined,
|
||||||
|
email: email || undefined,
|
||||||
|
pageUrl: pathname,
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
viewportWidth: window.innerWidth,
|
||||||
|
viewportHeight: window.innerHeight,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success("Feedback submitted, thank you!")
|
||||||
|
resetForm()
|
||||||
|
setDialogOpen(false)
|
||||||
|
} else {
|
||||||
|
const data = await res.json() as { error?: string }
|
||||||
|
toast.error(data.error || "Something went wrong")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to submit feedback")
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FeedbackContext.Provider value={{ open: () => setDialogOpen(true) }}>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => setDialogOpen(true)}
|
||||||
|
size="icon-lg"
|
||||||
|
className="group fixed bottom-12 right-6 z-40 gap-0 rounded-full shadow-lg transition-all duration-200 hover:w-auto hover:gap-2 hover:px-4 overflow-hidden"
|
||||||
|
>
|
||||||
|
<MessageCircle className="size-5 shrink-0" />
|
||||||
|
<span className="max-w-0 overflow-hidden whitespace-nowrap opacity-0 transition-all duration-200 group-hover:max-w-40 group-hover:opacity-100">
|
||||||
|
Feedback
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Send Feedback</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Report a bug, request a feature, or ask a question.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="grid gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="feedback-type">Type</Label>
|
||||||
|
<Select value={type} onValueChange={setType}>
|
||||||
|
<SelectTrigger id="feedback-type">
|
||||||
|
<SelectValue placeholder="Select type..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="bug">Bug Report</SelectItem>
|
||||||
|
<SelectItem value="feature">Feature Request</SelectItem>
|
||||||
|
<SelectItem value="question">Question</SelectItem>
|
||||||
|
<SelectItem value="general">General Feedback</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="feedback-message">Message</Label>
|
||||||
|
<Textarea
|
||||||
|
id="feedback-message"
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
placeholder="Describe your feedback..."
|
||||||
|
maxLength={2000}
|
||||||
|
rows={4}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground text-right">
|
||||||
|
{message.length}/2000
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="feedback-name">Name (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="feedback-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Your name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="feedback-email">Email (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="feedback-email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={submitting || !type || !message.trim()}>
|
||||||
|
{submitting ? "Submitting..." : "Submit Feedback"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</FeedbackContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -23,11 +23,13 @@ import {
|
|||||||
import { SidebarTrigger } from "@/components/ui/sidebar"
|
import { SidebarTrigger } from "@/components/ui/sidebar"
|
||||||
import { NotificationsPopover } from "@/components/notifications-popover"
|
import { NotificationsPopover } from "@/components/notifications-popover"
|
||||||
import { useCommandMenu } from "@/components/command-menu-provider"
|
import { useCommandMenu } from "@/components/command-menu-provider"
|
||||||
|
import { useFeedback } from "@/components/feedback-widget"
|
||||||
import { AccountModal } from "@/components/account-modal"
|
import { AccountModal } from "@/components/account-modal"
|
||||||
|
|
||||||
export function SiteHeader() {
|
export function SiteHeader() {
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
const { open: openCommand } = useCommandMenu()
|
const { open: openCommand } = useCommandMenu()
|
||||||
|
const { open: openFeedback } = useFeedback()
|
||||||
const [accountOpen, setAccountOpen] = React.useState(false)
|
const [accountOpen, setAccountOpen] = React.useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -55,6 +57,14 @@ export function SiteHeader() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-muted-foreground text-xs"
|
||||||
|
onClick={openFeedback}
|
||||||
|
>
|
||||||
|
Feedback
|
||||||
|
</Button>
|
||||||
<NotificationsPopover />
|
<NotificationsPopover />
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -62,11 +72,8 @@ export function SiteHeader() {
|
|||||||
className="size-8"
|
className="size-8"
|
||||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||||
>
|
>
|
||||||
{theme === "dark" ? (
|
<IconSun className="size-4 hidden dark:block" />
|
||||||
<IconSun className="size-4" />
|
<IconMoon className="size-4 block dark:hidden" />
|
||||||
) : (
|
|
||||||
<IconMoon className="size-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
|
|||||||
@ -105,5 +105,22 @@ export type ScheduleBaseline = typeof scheduleBaselines.$inferSelect
|
|||||||
export type NewScheduleBaseline = typeof scheduleBaselines.$inferInsert
|
export type NewScheduleBaseline = typeof scheduleBaselines.$inferInsert
|
||||||
export type Customer = typeof customers.$inferSelect
|
export type Customer = typeof customers.$inferSelect
|
||||||
export type NewCustomer = typeof customers.$inferInsert
|
export type NewCustomer = typeof customers.$inferInsert
|
||||||
|
export const feedback = sqliteTable("feedback", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
type: text("type").notNull(),
|
||||||
|
message: text("message").notNull(),
|
||||||
|
name: text("name"),
|
||||||
|
email: text("email"),
|
||||||
|
pageUrl: text("page_url"),
|
||||||
|
userAgent: text("user_agent"),
|
||||||
|
viewportWidth: integer("viewport_width"),
|
||||||
|
viewportHeight: integer("viewport_height"),
|
||||||
|
ipHash: text("ip_hash"),
|
||||||
|
githubIssueUrl: text("github_issue_url"),
|
||||||
|
createdAt: text("created_at").notNull(),
|
||||||
|
})
|
||||||
|
|
||||||
export type Vendor = typeof vendors.$inferSelect
|
export type Vendor = typeof vendors.$inferSelect
|
||||||
export type NewVendor = typeof vendors.$inferInsert
|
export type NewVendor = typeof vendors.$inferInsert
|
||||||
|
export type Feedback = typeof feedback.$inferSelect
|
||||||
|
export type NewFeedback = typeof feedback.$inferInsert
|
||||||
|
|||||||
32
src/types/frappe-gantt.d.ts
vendored
Executable file
32
src/types/frappe-gantt.d.ts
vendored
Executable file
@ -0,0 +1,32 @@
|
|||||||
|
declare module "frappe-gantt" {
|
||||||
|
interface GanttTask {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
start: string
|
||||||
|
end: string
|
||||||
|
progress: number
|
||||||
|
dependencies?: string
|
||||||
|
custom_class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GanttOptions {
|
||||||
|
view_mode?: string
|
||||||
|
on_date_change?: (
|
||||||
|
task: { id: string },
|
||||||
|
start: Date,
|
||||||
|
end: Date
|
||||||
|
) => void
|
||||||
|
on_progress_change?: (
|
||||||
|
task: { id: string },
|
||||||
|
progress: number
|
||||||
|
) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class Gantt {
|
||||||
|
constructor(
|
||||||
|
element: HTMLElement,
|
||||||
|
tasks: GanttTask[],
|
||||||
|
options?: GanttOptions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,35 +1,34 @@
|
|||||||
/**
|
|
||||||
* For more details on how to configure Wrangler, refer to:
|
|
||||||
* https://developers.cloudflare.com/workers/wrangler/configuration/
|
|
||||||
*/
|
|
||||||
/**
|
/**
|
||||||
* For more details on how to configure Wrangler, refer to:
|
* For more details on how to configure Wrangler, refer to:
|
||||||
* https://developers.cloudflare.com/workers/wrangler/configuration/
|
* https://developers.cloudflare.com/workers/wrangler/configuration/
|
||||||
*/
|
*/
|
||||||
{
|
{
|
||||||
"$schema": "node_modules/wrangler/config-schema.json",
|
"$schema": "node_modules/wrangler/config-schema.json",
|
||||||
"name": "dashboard-app-template",
|
"name": "compass",
|
||||||
|
"account_id": "8716137c706ea3d5c209b330084fa9e2",
|
||||||
"main": ".open-next/worker.js",
|
"main": ".open-next/worker.js",
|
||||||
"compatibility_date": "2025-12-01",
|
"compatibility_date": "2025-12-01",
|
||||||
"compatibility_flags": [
|
"compatibility_flags": [
|
||||||
"nodejs_compat",
|
"nodejs_compat",
|
||||||
"global_fetch_strictly_public"
|
"global_fetch_strictly_public"
|
||||||
],
|
],
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"pattern": "compass.openrangeconstruction.ltd",
|
||||||
|
"custom_domain": true
|
||||||
|
}
|
||||||
|
],
|
||||||
"assets": {
|
"assets": {
|
||||||
"binding": "ASSETS",
|
"binding": "ASSETS",
|
||||||
"directory": ".open-next/assets"
|
"directory": ".open-next/assets"
|
||||||
},
|
},
|
||||||
"images": {
|
"images": {
|
||||||
// Enable image optimization
|
|
||||||
// see https://opennext.js.org/cloudflare/howtos/image
|
|
||||||
"binding": "IMAGES"
|
"binding": "IMAGES"
|
||||||
},
|
},
|
||||||
"services": [
|
"services": [
|
||||||
{
|
{
|
||||||
// Self-reference service binding, the service name must match the worker name
|
|
||||||
// see https://opennext.js.org/cloudflare/caching
|
|
||||||
"binding": "WORKER_SELF_REFERENCE",
|
"binding": "WORKER_SELF_REFERENCE",
|
||||||
"service": "dashboard-app-template"
|
"service": "compass"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"observability": {
|
"observability": {
|
||||||
@ -39,26 +38,8 @@
|
|||||||
{
|
{
|
||||||
"binding": "DB",
|
"binding": "DB",
|
||||||
"database_name": "compass-db",
|
"database_name": "compass-db",
|
||||||
"database_id": "placeholder-run-wrangler-d1-create",
|
"database_id": "cd6983ff-d286-4042-a823-6b2433c9fba7",
|
||||||
"migrations_dir": "drizzle"
|
"migrations_dir": "drizzle"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
/**
|
|
||||||
* Smart Placement
|
|
||||||
* https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
|
|
||||||
*/
|
|
||||||
// "placement": { "mode": "smart" }
|
|
||||||
/**
|
|
||||||
* Bindings
|
|
||||||
* Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including
|
|
||||||
* databases, object storage, AI inference, real-time communication and more.
|
|
||||||
* https://developers.cloudflare.com/workers/runtime-apis/bindings/
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Environment Variables
|
|
||||||
* https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables
|
|
||||||
* Note: Use secrets to store sensitive data.
|
|
||||||
* https://developers.cloudflare.com/workers/configuration/secrets/
|
|
||||||
*/
|
|
||||||
// "vars": { "MY_VARIABLE": "production_value" }
|
|
||||||
}
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user