Add business nickname support across app and API

This commit is contained in:
2025-08-11 01:50:20 -04:00
parent 93ffdf3c86
commit a680f89a46
19 changed files with 308 additions and 1193 deletions

View File

@@ -1,130 +0,0 @@
CREATE TABLE "beenvoice_account" (
"userId" varchar(255) NOT NULL,
"type" varchar(255) NOT NULL,
"provider" varchar(255) NOT NULL,
"providerAccountId" varchar(255) NOT NULL,
"refresh_token" text,
"access_token" text,
"expires_at" integer,
"token_type" varchar(255),
"scope" varchar(255),
"id_token" text,
"session_state" varchar(255),
CONSTRAINT "beenvoice_account_provider_providerAccountId_pk" PRIMARY KEY("provider","providerAccountId")
);
--> statement-breakpoint
CREATE TABLE "beenvoice_business" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"name" varchar(255) NOT NULL,
"email" varchar(255),
"phone" varchar(50),
"addressLine1" varchar(255),
"addressLine2" varchar(255),
"city" varchar(100),
"state" varchar(50),
"postalCode" varchar(20),
"country" varchar(100),
"website" varchar(255),
"taxId" varchar(100),
"logoUrl" varchar(500),
"isDefault" boolean DEFAULT false,
"resendApiKey" varchar(255),
"resendDomain" varchar(255),
"emailFromName" varchar(255),
"createdById" varchar(255) NOT NULL,
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp
);
--> statement-breakpoint
CREATE TABLE "beenvoice_client" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"name" varchar(255) NOT NULL,
"email" varchar(255),
"phone" varchar(50),
"addressLine1" varchar(255),
"addressLine2" varchar(255),
"city" varchar(100),
"state" varchar(50),
"postalCode" varchar(20),
"country" varchar(100),
"defaultHourlyRate" real DEFAULT 100 NOT NULL,
"createdById" varchar(255) NOT NULL,
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp
);
--> statement-breakpoint
CREATE TABLE "beenvoice_invoice_item" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"invoiceId" varchar(255) NOT NULL,
"date" timestamp NOT NULL,
"description" varchar(500) NOT NULL,
"hours" real NOT NULL,
"rate" real NOT NULL,
"amount" real NOT NULL,
"position" integer DEFAULT 0 NOT NULL,
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE TABLE "beenvoice_invoice" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"invoiceNumber" varchar(100) NOT NULL,
"businessId" varchar(255),
"clientId" varchar(255) NOT NULL,
"issueDate" timestamp NOT NULL,
"dueDate" timestamp NOT NULL,
"status" varchar(50) DEFAULT 'draft' NOT NULL,
"totalAmount" real DEFAULT 0 NOT NULL,
"taxRate" real DEFAULT 0 NOT NULL,
"notes" varchar(1000),
"createdById" varchar(255) NOT NULL,
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp
);
--> statement-breakpoint
CREATE TABLE "beenvoice_session" (
"sessionToken" varchar(255) PRIMARY KEY NOT NULL,
"userId" varchar(255) NOT NULL,
"expires" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "beenvoice_user" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"name" varchar(255),
"email" varchar(255) NOT NULL,
"password" varchar(255),
"emailVerified" timestamp DEFAULT CURRENT_TIMESTAMP,
"image" varchar(255)
);
--> statement-breakpoint
CREATE TABLE "beenvoice_verification_token" (
"identifier" varchar(255) NOT NULL,
"token" varchar(255) NOT NULL,
"expires" timestamp NOT NULL,
CONSTRAINT "beenvoice_verification_token_identifier_token_pk" PRIMARY KEY("identifier","token")
);
--> statement-breakpoint
ALTER TABLE "beenvoice_account" ADD CONSTRAINT "beenvoice_account_userId_beenvoice_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_business" ADD CONSTRAINT "beenvoice_business_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_client" ADD CONSTRAINT "beenvoice_client_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_invoice_item" ADD CONSTRAINT "beenvoice_invoice_item_invoiceId_beenvoice_invoice_id_fk" FOREIGN KEY ("invoiceId") REFERENCES "public"."beenvoice_invoice"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ADD CONSTRAINT "beenvoice_invoice_businessId_beenvoice_business_id_fk" FOREIGN KEY ("businessId") REFERENCES "public"."beenvoice_business"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ADD CONSTRAINT "beenvoice_invoice_clientId_beenvoice_client_id_fk" FOREIGN KEY ("clientId") REFERENCES "public"."beenvoice_client"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ADD CONSTRAINT "beenvoice_invoice_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_session" ADD CONSTRAINT "beenvoice_session_userId_beenvoice_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "account_user_id_idx" ON "beenvoice_account" USING btree ("userId");--> statement-breakpoint
CREATE INDEX "business_created_by_idx" ON "beenvoice_business" USING btree ("createdById");--> statement-breakpoint
CREATE INDEX "business_name_idx" ON "beenvoice_business" USING btree ("name");--> statement-breakpoint
CREATE INDEX "business_email_idx" ON "beenvoice_business" USING btree ("email");--> statement-breakpoint
CREATE INDEX "business_is_default_idx" ON "beenvoice_business" USING btree ("isDefault");--> statement-breakpoint
CREATE INDEX "client_created_by_idx" ON "beenvoice_client" USING btree ("createdById");--> statement-breakpoint
CREATE INDEX "client_name_idx" ON "beenvoice_client" USING btree ("name");--> statement-breakpoint
CREATE INDEX "client_email_idx" ON "beenvoice_client" USING btree ("email");--> statement-breakpoint
CREATE INDEX "invoice_item_invoice_id_idx" ON "beenvoice_invoice_item" USING btree ("invoiceId");--> statement-breakpoint
CREATE INDEX "invoice_item_date_idx" ON "beenvoice_invoice_item" USING btree ("date");--> statement-breakpoint
CREATE INDEX "invoice_item_position_idx" ON "beenvoice_invoice_item" USING btree ("position");--> statement-breakpoint
CREATE INDEX "invoice_business_id_idx" ON "beenvoice_invoice" USING btree ("businessId");--> statement-breakpoint
CREATE INDEX "invoice_client_id_idx" ON "beenvoice_invoice" USING btree ("clientId");--> statement-breakpoint
CREATE INDEX "invoice_created_by_idx" ON "beenvoice_invoice" USING btree ("createdById");--> statement-breakpoint
CREATE INDEX "invoice_number_idx" ON "beenvoice_invoice" USING btree ("invoiceNumber");--> statement-breakpoint
CREATE INDEX "invoice_status_idx" ON "beenvoice_invoice" USING btree ("status");--> statement-breakpoint
CREATE INDEX "session_userId_idx" ON "beenvoice_session" USING btree ("userId");

View File

@@ -1,995 +0,0 @@
{
"id": "c86256e7-28b4-48ea-8104-933cbff6e217",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.beenvoice_account": {
"name": "beenvoice_account",
"schema": "",
"columns": {
"userId": {
"name": "userId",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"provider": {
"name": "provider",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"providerAccountId": {
"name": "providerAccountId",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"token_type": {
"name": "token_type",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"session_state": {
"name": "session_state",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"account_user_id_idx": {
"name": "account_user_id_idx",
"columns": [
{
"expression": "userId",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"beenvoice_account_userId_beenvoice_user_id_fk": {
"name": "beenvoice_account_userId_beenvoice_user_id_fk",
"tableFrom": "beenvoice_account",
"tableTo": "beenvoice_user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"beenvoice_account_provider_providerAccountId_pk": {
"name": "beenvoice_account_provider_providerAccountId_pk",
"columns": [
"provider",
"providerAccountId"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.beenvoice_business": {
"name": "beenvoice_business",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "varchar(255)",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"phone": {
"name": "phone",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false
},
"addressLine1": {
"name": "addressLine1",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"addressLine2": {
"name": "addressLine2",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"city": {
"name": "city",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false
},
"state": {
"name": "state",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false
},
"postalCode": {
"name": "postalCode",
"type": "varchar(20)",
"primaryKey": false,
"notNull": false
},
"country": {
"name": "country",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false
},
"website": {
"name": "website",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"taxId": {
"name": "taxId",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false
},
"logoUrl": {
"name": "logoUrl",
"type": "varchar(500)",
"primaryKey": false,
"notNull": false
},
"isDefault": {
"name": "isDefault",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"resendApiKey": {
"name": "resendApiKey",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"resendDomain": {
"name": "resendDomain",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"emailFromName": {
"name": "emailFromName",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"createdById": {
"name": "createdById",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "CURRENT_TIMESTAMP"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"business_created_by_idx": {
"name": "business_created_by_idx",
"columns": [
{
"expression": "createdById",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"business_name_idx": {
"name": "business_name_idx",
"columns": [
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"business_email_idx": {
"name": "business_email_idx",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"business_is_default_idx": {
"name": "business_is_default_idx",
"columns": [
{
"expression": "isDefault",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"beenvoice_business_createdById_beenvoice_user_id_fk": {
"name": "beenvoice_business_createdById_beenvoice_user_id_fk",
"tableFrom": "beenvoice_business",
"tableTo": "beenvoice_user",
"columnsFrom": [
"createdById"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.beenvoice_client": {
"name": "beenvoice_client",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "varchar(255)",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"phone": {
"name": "phone",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false
},
"addressLine1": {
"name": "addressLine1",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"addressLine2": {
"name": "addressLine2",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"city": {
"name": "city",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false
},
"state": {
"name": "state",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false
},
"postalCode": {
"name": "postalCode",
"type": "varchar(20)",
"primaryKey": false,
"notNull": false
},
"country": {
"name": "country",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false
},
"defaultHourlyRate": {
"name": "defaultHourlyRate",
"type": "real",
"primaryKey": false,
"notNull": true,
"default": 100
},
"createdById": {
"name": "createdById",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "CURRENT_TIMESTAMP"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"client_created_by_idx": {
"name": "client_created_by_idx",
"columns": [
{
"expression": "createdById",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"client_name_idx": {
"name": "client_name_idx",
"columns": [
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"client_email_idx": {
"name": "client_email_idx",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"beenvoice_client_createdById_beenvoice_user_id_fk": {
"name": "beenvoice_client_createdById_beenvoice_user_id_fk",
"tableFrom": "beenvoice_client",
"tableTo": "beenvoice_user",
"columnsFrom": [
"createdById"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.beenvoice_invoice_item": {
"name": "beenvoice_invoice_item",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "varchar(255)",
"primaryKey": true,
"notNull": true
},
"invoiceId": {
"name": "invoiceId",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"date": {
"name": "date",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "varchar(500)",
"primaryKey": false,
"notNull": true
},
"hours": {
"name": "hours",
"type": "real",
"primaryKey": false,
"notNull": true
},
"rate": {
"name": "rate",
"type": "real",
"primaryKey": false,
"notNull": true
},
"amount": {
"name": "amount",
"type": "real",
"primaryKey": false,
"notNull": true
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"invoice_item_invoice_id_idx": {
"name": "invoice_item_invoice_id_idx",
"columns": [
{
"expression": "invoiceId",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"invoice_item_date_idx": {
"name": "invoice_item_date_idx",
"columns": [
{
"expression": "date",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"invoice_item_position_idx": {
"name": "invoice_item_position_idx",
"columns": [
{
"expression": "position",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"beenvoice_invoice_item_invoiceId_beenvoice_invoice_id_fk": {
"name": "beenvoice_invoice_item_invoiceId_beenvoice_invoice_id_fk",
"tableFrom": "beenvoice_invoice_item",
"tableTo": "beenvoice_invoice",
"columnsFrom": [
"invoiceId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.beenvoice_invoice": {
"name": "beenvoice_invoice",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "varchar(255)",
"primaryKey": true,
"notNull": true
},
"invoiceNumber": {
"name": "invoiceNumber",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true
},
"businessId": {
"name": "businessId",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"clientId": {
"name": "clientId",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"issueDate": {
"name": "issueDate",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"dueDate": {
"name": "dueDate",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"default": "'draft'"
},
"totalAmount": {
"name": "totalAmount",
"type": "real",
"primaryKey": false,
"notNull": true,
"default": 0
},
"taxRate": {
"name": "taxRate",
"type": "real",
"primaryKey": false,
"notNull": true,
"default": 0
},
"notes": {
"name": "notes",
"type": "varchar(1000)",
"primaryKey": false,
"notNull": false
},
"createdById": {
"name": "createdById",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "CURRENT_TIMESTAMP"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"invoice_business_id_idx": {
"name": "invoice_business_id_idx",
"columns": [
{
"expression": "businessId",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"invoice_client_id_idx": {
"name": "invoice_client_id_idx",
"columns": [
{
"expression": "clientId",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"invoice_created_by_idx": {
"name": "invoice_created_by_idx",
"columns": [
{
"expression": "createdById",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"invoice_number_idx": {
"name": "invoice_number_idx",
"columns": [
{
"expression": "invoiceNumber",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"invoice_status_idx": {
"name": "invoice_status_idx",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"beenvoice_invoice_businessId_beenvoice_business_id_fk": {
"name": "beenvoice_invoice_businessId_beenvoice_business_id_fk",
"tableFrom": "beenvoice_invoice",
"tableTo": "beenvoice_business",
"columnsFrom": [
"businessId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"beenvoice_invoice_clientId_beenvoice_client_id_fk": {
"name": "beenvoice_invoice_clientId_beenvoice_client_id_fk",
"tableFrom": "beenvoice_invoice",
"tableTo": "beenvoice_client",
"columnsFrom": [
"clientId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"beenvoice_invoice_createdById_beenvoice_user_id_fk": {
"name": "beenvoice_invoice_createdById_beenvoice_user_id_fk",
"tableFrom": "beenvoice_invoice",
"tableTo": "beenvoice_user",
"columnsFrom": [
"createdById"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.beenvoice_session": {
"name": "beenvoice_session",
"schema": "",
"columns": {
"sessionToken": {
"name": "sessionToken",
"type": "varchar(255)",
"primaryKey": true,
"notNull": true
},
"userId": {
"name": "userId",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"session_userId_idx": {
"name": "session_userId_idx",
"columns": [
{
"expression": "userId",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"beenvoice_session_userId_beenvoice_user_id_fk": {
"name": "beenvoice_session_userId_beenvoice_user_id_fk",
"tableFrom": "beenvoice_session",
"tableTo": "beenvoice_user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.beenvoice_user": {
"name": "beenvoice_user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "varchar(255)",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"password": {
"name": "password",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"emailVerified": {
"name": "emailVerified",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "CURRENT_TIMESTAMP"
},
"image": {
"name": "image",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.beenvoice_verification_token": {
"name": "beenvoice_verification_token",
"schema": "",
"columns": {
"identifier": {
"name": "identifier",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"beenvoice_verification_token_identifier_token_pk": {
"name": "beenvoice_verification_token_identifier_token_pk",
"columns": [
"identifier",
"token"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -1,13 +0,0 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1753825898609,
"tag": "0000_warm_squadron_sinister",
"breakpoints": true
}
]
}

View File

@@ -45,7 +45,7 @@ export default async function BusinessDetailPage({
return ( return (
<div className="space-y-6 pb-32"> <div className="space-y-6 pb-32">
<PageHeader <PageHeader
title={business.name} title={`${business.name}${business.nickname ? ` (${business.nickname})` : ""}`}
description="View business details and information" description="View business details and information"
variant="gradient" variant="gradient"
> >
@@ -69,7 +69,7 @@ export default async function BusinessDetailPage({
<Card className="bg-card border-border border"> <Card className="bg-card border-border border">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<div className="bg-primary/10 p-2"> <div className="bg-primary/10 p-2">
<Building className="text-primary h-5 w-5" /> <Building className="text-primary h-5 w-5" />
</div> </div>
<span>Business Information</span> <span>Business Information</span>
@@ -84,7 +84,7 @@ export default async function BusinessDetailPage({
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{business.email && ( {business.email && (
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="bg-primary/10 p-2"> <div className="bg-primary/10 p-2">
<Mail className="text-primary h-4 w-4" /> <Mail className="text-primary h-4 w-4" />
</div> </div>
<div> <div>
@@ -100,7 +100,7 @@ export default async function BusinessDetailPage({
{business.phone && ( {business.phone && (
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="bg-primary/10 p-2"> <div className="bg-primary/10 p-2">
<Phone className="text-primary h-4 w-4" /> <Phone className="text-primary h-4 w-4" />
</div> </div>
<div> <div>
@@ -116,7 +116,7 @@ export default async function BusinessDetailPage({
{business.website && ( {business.website && (
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="bg-primary/10 p-2"> <div className="bg-primary/10 p-2">
<Globe className="text-primary h-4 w-4" /> <Globe className="text-primary h-4 w-4" />
</div> </div>
<div> <div>
@@ -137,7 +137,7 @@ export default async function BusinessDetailPage({
{business.taxId && ( {business.taxId && (
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="bg-primary/10 p-2"> <div className="bg-primary/10 p-2">
<Hash className="text-primary h-4 w-4" /> <Hash className="text-primary h-4 w-4" />
</div> </div>
<div> <div>
@@ -162,7 +162,7 @@ export default async function BusinessDetailPage({
Business Address Business Address
</h3> </h3>
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<div className="bg-primary/10 p-2"> <div className="bg-primary/10 p-2">
<MapPin className="text-primary h-4 w-4" /> <MapPin className="text-primary h-4 w-4" />
</div> </div>
<div className="space-y-1 text-sm"> <div className="space-y-1 text-sm">
@@ -205,7 +205,7 @@ export default async function BusinessDetailPage({
<h3 className="mb-4 text-lg font-semibold">Business Details</h3> <h3 className="mb-4 text-lg font-semibold">Business Details</h3>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="bg-primary/10 p-2"> <div className="bg-primary/10 p-2">
<Calendar className="text-primary h-4 w-4" /> <Calendar className="text-primary h-4 w-4" />
</div> </div>
<div> <div>
@@ -218,10 +218,31 @@ export default async function BusinessDetailPage({
</div> </div>
</div> </div>
{business.nickname && (
<div className="flex items-center space-x-3">
<div className="bg-primary/10 p-2">
<Building className="text-primary h-4 w-4" />
</div>
<div>
<div className="flex items-center gap-2">
<p className="text-muted-foreground text-sm font-medium">
Nickname
</p>
<Badge variant="outline" className="text-xs">
Internal only
</Badge>
</div>
<p className="text-foreground text-sm">
{business.nickname}
</p>
</div>
</div>
)}
{/* Default Business Badge */} {/* Default Business Badge */}
{business.isDefault && ( {business.isDefault && (
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="bg-primary/10 p-2"> <div className="bg-primary/10 p-2">
<Building className="text-primary h-4 w-4" /> <Building className="text-primary h-4 w-4" />
</div> </div>
<div> <div>
@@ -248,7 +269,7 @@ export default async function BusinessDetailPage({
<Card className="bg-card border-border border"> <Card className="bg-card border-border border">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<div className="bg-primary/10 p-2"> <div className="bg-primary/10 p-2">
<Building className="text-primary h-5 w-5" /> <Building className="text-primary h-5 w-5" />
</div> </div>
<span>Quick Actions</span> <span>Quick Actions</span>

View File

@@ -22,6 +22,7 @@ import { toast } from "sonner";
interface Business { interface Business {
id: string; id: string;
name: string; name: string;
nickname: string | null;
email: string | null; email: string | null;
phone: string | null; phone: string | null;
addressLine1: string | null; addressLine1: string | null;
@@ -61,6 +62,11 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
const utils = api.useUtils(); const utils = api.useUtils();
const searchableBusinesses = businesses.map((b) => ({
...b,
searchValue: `${b.name} ${b.nickname ?? ""}`.trim(),
}));
const deleteBusinessMutation = api.businesses.delete.useMutation({ const deleteBusinessMutation = api.businesses.delete.useMutation({
onSuccess: () => { onSuccess: () => {
toast.success("Business deleted successfully"); toast.success("Business deleted successfully");
@@ -91,7 +97,7 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
const business = row.original; const business = row.original;
return ( return (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="bg-primary/10 hidden p-2 sm:flex"> <div className="bg-primary/10 hidden p-2 sm:flex">
<Building className="text-primary h-4 w-4" /> <Building className="text-primary h-4 w-4" />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
@@ -104,6 +110,17 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
); );
}, },
}, },
{
accessorKey: "nickname",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Nickname" />
),
cell: ({ row }) => row.original.nickname ?? "—",
meta: {
headerClassName: "hidden sm:table-cell",
cellClassName: "hidden sm:table-cell",
},
},
{ {
accessorKey: "phone", accessorKey: "phone",
header: ({ column }) => ( header: ({ column }) => (
@@ -175,6 +192,15 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
); );
}, },
}, },
{
accessorKey: "searchValue",
header: "Search",
cell: () => null,
meta: {
headerClassName: "hidden",
cellClassName: "hidden",
},
},
{ {
id: "actions", id: "actions",
cell: ({ row }) => { cell: ({ row }) => {
@@ -210,9 +236,9 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
<> <>
<DataTable <DataTable
columns={columns} columns={columns}
data={businesses} data={searchableBusinesses}
searchKey="name" searchKey="searchValue"
searchPlaceholder="Search businesses..." searchPlaceholder="Search by name or nickname..."
onRowClick={handleRowClick} onRowClick={handleRowClick}
/> />

View File

@@ -31,7 +31,7 @@ export default async function BusinessesPage() {
</PageHeader> </PageHeader>
<HydrateClient> <HydrateClient>
<Suspense fallback={<DataTableSkeleton columns={6} rows={5} />}> <Suspense fallback={<DataTableSkeleton columns={7} rows={5} />}>
<BusinessesTable /> <BusinessesTable />
</Suspense> </Suspense>
</HydrateClient> </HydrateClient>

View File

@@ -165,6 +165,7 @@ export default function SendEmailPage() {
business: invoiceData.business business: invoiceData.business
? { ? {
name: invoiceData.business.name, name: invoiceData.business.name,
nickname: invoiceData.business.nickname,
email: invoiceData.business.email, email: invoiceData.business.email,
} }
: undefined, : undefined,

View File

@@ -98,6 +98,7 @@ export function DataTable<TData, TValue>({
React.useState<VisibilityState>({}); React.useState<VisibilityState>({});
const [rowSelection, setRowSelection] = React.useState({}); const [rowSelection, setRowSelection] = React.useState({});
const [globalFilter, setGlobalFilter] = React.useState(""); const [globalFilter, setGlobalFilter] = React.useState("");
const [searchInput, setSearchInput] = React.useState("");
// Mobile detection hook // Mobile detection hook
const [isMobile, setIsMobile] = React.useState(false); const [isMobile, setIsMobile] = React.useState(false);
@@ -171,6 +172,19 @@ export function DataTable<TData, TValue>({
table.setPageSize(isMobile ? 5 : pageSize); table.setPageSize(isMobile ? 5 : pageSize);
}, [isMobile, pageSize, table]); }, [isMobile, pageSize, table]);
// Debounce search input updates to the table's global filter
React.useEffect(() => {
const timeout = setTimeout(() => {
setGlobalFilter(searchInput);
}, 300);
return () => clearTimeout(timeout);
}, [searchInput]);
// Keep search input in sync when globalFilter is changed externally (e.g., "Clear filters")
React.useEffect(() => {
setSearchInput(globalFilter ?? "");
}, [globalFilter]);
const pageSizeOptions = [5, 10, 20, 30, 50, 100]; const pageSizeOptions = [5, 10, 20, 30, 50, 100];
// Handle row click // Handle row click
@@ -223,8 +237,8 @@ export function DataTable<TData, TValue>({
<Search className="text-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" /> <Search className="text-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input <Input
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
value={globalFilter ?? ""} value={searchInput ?? ""}
onChange={(event) => setGlobalFilter(event.target.value)} onChange={(event) => setSearchInput(event.target.value)}
className="h-9 w-full pr-3 pl-9" className="h-9 w-full pr-3 pl-9"
/> />
</div> </div>

View File

@@ -188,7 +188,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="bg-primary/10 p-2"> <div className="bg-primary/10 p-2">
<FileText className="text-primary h-6 w-6" /> <FileText className="text-primary h-6 w-6" />
</div> </div>
<div> <div>
@@ -239,7 +239,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
> >
{isExportingPDF ? ( {isExportingPDF ? (
<> <>
<div className="mr-2 h-4 w-4 animate-spin border-2 border-white border-t-transparent" /> <div className="mr-2 h-4 w-4 animate-spin border-2 border-white border-t-transparent" />
Generating PDF... Generating PDF...
</> </>
) : ( ) : (
@@ -326,7 +326,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="border-border overflow-hidden border"> <div className="border-border overflow-hidden border">
<table className="w-full"> <table className="w-full">
<thead className="bg-muted"> <thead className="bg-muted">
<tr> <tr>
@@ -479,7 +479,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
</Card> </Card>
{/* Danger Zone */} {/* Danger Zone */}
<Card className="bg-card border-border border border-destructive/20"> <Card className="bg-card border-destructive/20 border">
<CardHeader> <CardHeader>
<CardTitle className="text-destructive">Danger Zone</CardTitle> <CardTitle className="text-destructive">Danger Zone</CardTitle>
</CardHeader> </CardHeader>

View File

@@ -46,6 +46,7 @@ interface BusinessFormProps {
interface FormData { interface FormData {
name: string; name: string;
nickname: string;
email: string; email: string;
phone: string; phone: string;
addressLine1: string; addressLine1: string;
@@ -64,6 +65,7 @@ interface FormData {
interface FormErrors { interface FormErrors {
name?: string; name?: string;
nickname?: string;
email?: string; email?: string;
phone?: string; phone?: string;
addressLine1?: string; addressLine1?: string;
@@ -80,6 +82,7 @@ interface FormErrors {
const initialFormData: FormData = { const initialFormData: FormData = {
name: "", name: "",
nickname: "",
email: "", email: "",
phone: "", phone: "",
addressLine1: "", addressLine1: "",
@@ -153,6 +156,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
if (business && mode === "edit") { if (business && mode === "edit") {
setFormData({ setFormData({
name: business.name, name: business.name,
nickname: business.nickname ?? "",
email: business.email ?? "", email: business.email ?? "",
phone: business.phone ?? "", phone: business.phone ?? "",
addressLine1: business.addressLine1 ?? "", addressLine1: business.addressLine1 ?? "",
@@ -198,6 +202,10 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
if (!formData.name.trim()) { if (!formData.name.trim()) {
newErrors.name = VALIDATION_MESSAGES.required; newErrors.name = VALIDATION_MESSAGES.required;
} }
// Nickname validation (optional, max 255 chars)
if (formData.nickname && formData.nickname.length > 255) {
newErrors.nickname = "Nickname must be 255 characters or less";
}
// Email validation // Email validation
if (formData.email && !isValidEmail(formData.email)) { if (formData.email && !isValidEmail(formData.email)) {
@@ -280,6 +288,8 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
// Format website URL before submission // Format website URL before submission
const dataToSubmit = { const dataToSubmit = {
...formData, ...formData,
name: formData.name.trim(),
nickname: formData.nickname.trim(),
website: formData.website ? formatWebsiteUrl(formData.website) : "", website: formData.website ? formatWebsiteUrl(formData.website) : "",
}; };
@@ -287,6 +297,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
// Create business data (excluding email config fields) // Create business data (excluding email config fields)
const businessData = { const businessData = {
name: dataToSubmit.name, name: dataToSubmit.name,
nickname: dataToSubmit.nickname,
email: dataToSubmit.email, email: dataToSubmit.email,
phone: dataToSubmit.phone, phone: dataToSubmit.phone,
addressLine1: dataToSubmit.addressLine1, addressLine1: dataToSubmit.addressLine1,
@@ -320,6 +331,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
// Update business data (excluding email config fields) // Update business data (excluding email config fields)
const businessData = { const businessData = {
name: dataToSubmit.name, name: dataToSubmit.name,
nickname: dataToSubmit.nickname,
email: dataToSubmit.email, email: dataToSubmit.email,
phone: dataToSubmit.phone, phone: dataToSubmit.phone,
addressLine1: dataToSubmit.addressLine1, addressLine1: dataToSubmit.addressLine1,
@@ -442,7 +454,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
<Card className="bg-card border-border border"> <Card className="bg-card border-border border">
<CardHeader> <CardHeader>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="bg-muted flex h-10 w-10 items-center justify-center "> <div className="bg-muted flex h-10 w-10 items-center justify-center">
<Building className="text-muted-foreground h-5 w-5" /> <Building className="text-muted-foreground h-5 w-5" />
</div> </div>
<div> <div>
@@ -475,6 +487,29 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
)} )}
</div> </div>
<div className="space-y-2">
<Label htmlFor="nickname" className="text-sm font-medium">
Nickname
<span className="text-muted-foreground ml-1 text-xs font-normal">
(Optional)
</span>
</Label>
<Input
id="nickname"
value={formData.nickname}
onChange={(e) =>
handleInputChange("nickname", e.target.value)
}
placeholder="e.g., Personal, Work, LLC"
disabled={isSubmitting}
/>
{errors.nickname && (
<p className="text-destructive text-sm">
{errors.nickname}
</p>
)}
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="taxId" className="text-sm font-medium"> <Label htmlFor="taxId" className="text-sm font-medium">
Tax ID (EIN) Tax ID (EIN)
@@ -569,7 +604,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
<Card className="bg-card border-border border"> <Card className="bg-card border-border border">
<CardHeader> <CardHeader>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="bg-muted flex h-10 w-10 items-center justify-center "> <div className="bg-muted flex h-10 w-10 items-center justify-center">
<svg <svg
className="text-muted-foreground h-5 w-5" className="text-muted-foreground h-5 w-5"
fill="none" fill="none"
@@ -617,7 +652,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
<Card className="bg-card border-border border"> <Card className="bg-card border-border border">
<CardHeader> <CardHeader>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="bg-muted flex h-10 w-10 items-center justify-center "> <div className="bg-muted flex h-10 w-10 items-center justify-center">
<Mail className="text-muted-foreground h-5 w-5" /> <Mail className="text-muted-foreground h-5 w-5" />
</div> </div>
<div> <div>
@@ -632,7 +667,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{/* Current Status */} {/* Current Status */}
{mode === "edit" && ( {mode === "edit" && (
<div className="flex items-center justify-between bg-gray-50 p-4"> <div className="flex items-center justify-between bg-gray-50 p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium"> <span className="text-sm font-medium">
Current Status: Current Status:
@@ -806,7 +841,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
<Card className="bg-card border-border border"> <Card className="bg-card border-border border">
<CardHeader> <CardHeader>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="bg-muted flex h-10 w-10 items-center justify-center "> <div className="bg-muted flex h-10 w-10 items-center justify-center">
<Star className="text-muted-foreground h-5 w-5" /> <Star className="text-muted-foreground h-5 w-5" />
</div> </div>
<div> <div>
@@ -818,7 +853,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="bg-muted border-border/40 flex items-center justify-between border p-4"> <div className="bg-muted border-border/40 flex items-center justify-between border p-4">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label <Label
htmlFor="isDefault" htmlFor="isDefault"
@@ -848,7 +883,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
<FloatingActionBar <FloatingActionBar
leftContent={ leftContent={
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="bg-primary/10 p-2"> <div className="bg-primary/10 p-2">
<FileText className="text-primary h-5 w-5" /> <FileText className="text-primary h-5 w-5" />
</div> </div>
<div> <div>

View File

@@ -624,16 +624,22 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
updateField("businessId", value) updateField("businessId", value)
} }
> >
<SelectTrigger> <SelectTrigger
<SelectValue placeholder="Select your business" /> aria-label="From Business"
className="w-full"
>
<span className="min-w-0 flex-1 truncate text-left">
<SelectValue placeholder="Select your business (nickname shown)" />
</span>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent className="w-[--radix-select-trigger-width] min-w-[--radix-select-trigger-width]">
{businesses?.map((business) => ( {businesses?.map((business) => (
<SelectItem <SelectItem
key={business.id} key={business.id}
value={business.id} value={business.id}
className="truncate"
> >
{business.name} <span className="block truncate">{`${business.name}${business.nickname ? ` (${business.nickname})` : ""}`}</span>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -647,13 +653,24 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
updateField("clientId", value) updateField("clientId", value)
} }
> >
<SelectTrigger> <SelectTrigger
<SelectValue placeholder="Select a client" /> aria-label="Bill To Client"
className="w-full"
>
<span className="min-w-0 flex-1 truncate text-left">
<SelectValue placeholder="Select a client" />
</span>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent className="w-[--radix-select-trigger-width] min-w-[--radix-select-trigger-width]">
{clients?.map((client) => ( {clients?.map((client) => (
<SelectItem key={client.id} value={client.id}> <SelectItem
{client.name} key={client.id}
value={client.id}
className="truncate"
>
<span className="block truncate">
{client.name}
</span>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -789,10 +806,18 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</span> </span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Business:</span> <span className="text-muted-foreground">
Business (nickname shown):
</span>
<span className="font-medium"> <span className="font-medium">
{businesses?.find((b) => b.id === formData.businessId) {(() => {
?.name ?? "Not selected"} const b = businesses?.find(
(b) => b.id === formData.businessId,
);
return b
? `${b.name}${b.nickname ? ` (${b.nickname})` : ""}`
: "Not selected";
})()}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -42,14 +42,16 @@ function SelectTrigger({
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
className={cn( className={cn(
"data-[placeholder]:text-muted-foreground border-input bg-background text-foreground focus-visible:border-ring focus-visible:ring-ring/50 flex h-10 w-full items-center justify-between gap-2 border px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", "data-[placeholder]:text-muted-foreground border-input bg-background text-foreground focus-visible:border-ring focus-visible:ring-ring/50 relative flex h-10 w-full items-center justify-start gap-2 border px-3 py-2 pr-8 text-left text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className, className,
)} )}
{...props} {...props}
> >
{children} <span className="min-w-0 flex-1 truncate text-left">{children}</span>
<SelectPrimitive.Icon asChild> <SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" /> <span className="pointer-events-none absolute inset-y-0 right-2 flex items-center">
<ChevronDownIcon className="size-4 opacity-50" />
</span>
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
); );
@@ -66,7 +68,7 @@ function SelectContent({
<SelectPrimitive.Content <SelectPrimitive.Content
data-slot="select-content" data-slot="select-content"
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto border-0 shadow-md", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto border-0 shadow-md",
position === "popper" && position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className, className,
@@ -112,7 +114,7 @@ function SelectItem({
<SelectPrimitive.Item <SelectPrimitive.Item
data-slot="select-item" data-slot="select-item"
className={cn( className={cn(
"focus:bg-accent focus:text-foreground-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", "focus:bg-accent focus:text-foreground-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className, className,
)} )}
{...props} {...props}
@@ -210,7 +212,7 @@ function SelectContentWithSearch({
<SelectPrimitive.Content <SelectPrimitive.Content
data-slot="select-content" data-slot="select-content"
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-hidden border-0 shadow-md", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-hidden border-0 shadow-md",
position === "popper" && position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className, className,
@@ -235,7 +237,7 @@ function SelectContentWithSearch({
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" /> <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<input <input
ref={searchInputRef} ref={searchInputRef}
className="placeholder:text-muted-foreground text-foreground flex h-8 w-full border-0 bg-transparent py-2 text-sm outline-none focus:ring-0 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50" className="placeholder:text-muted-foreground text-foreground flex h-8 w-full border-0 bg-transparent py-2 text-sm outline-none focus:ring-0 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
value={searchValue} value={searchValue}
onChange={(e) => onSearchChange(e.target.value)} onChange={(e) => onSearchChange(e.target.value)}

View File

@@ -13,6 +13,7 @@ interface InvoiceEmailTemplateProps {
}; };
business?: { business?: {
name: string; name: string;
nickname?: string | null;
email?: string | null; email?: string | null;
phone?: string | null; phone?: string | null;
addressLine1?: string | null; addressLine1?: string | null;

View File

@@ -20,6 +20,7 @@ interface InvoiceData {
notes?: string | null; notes?: string | null;
business?: { business?: {
name: string; name: string;
nickname?: string | null;
email?: string | null; email?: string | null;
phone?: string | null; phone?: string | null;
addressLine1?: string | null; addressLine1?: string | null;

View File

@@ -6,7 +6,17 @@ import { invoices } from "~/server/db/schema";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
const businessSchema = z.object({ const businessSchema = z.object({
name: z.string().min(1, "Business name is required"), name: z
.string()
.trim()
.min(1, "Business name is required")
.max(255, "Business name must be 255 characters or less"),
nickname: z
.string()
.trim()
.max(255, "Nickname must be 255 characters or less")
.optional()
.or(z.literal("")),
email: z.string().email().optional().or(z.literal("")), email: z.string().email().optional().or(z.literal("")),
phone: z.string().optional().or(z.literal("")), phone: z.string().optional().or(z.literal("")),
addressLine1: z.string().optional().or(z.literal("")), addressLine1: z.string().optional().or(z.literal("")),
@@ -96,7 +106,54 @@ export const businessesRouter = createTRPCRouter({
const [newBusiness] = await ctx.db const [newBusiness] = await ctx.db
.insert(businesses) .insert(businesses)
.values({ .values({
...input, name: input.name.trim(),
nickname:
input.nickname && input.nickname.trim() !== ""
? input.nickname.trim()
: null,
email:
input.email && input.email.trim() !== ""
? input.email.trim()
: null,
phone:
input.phone && input.phone.trim() !== ""
? input.phone.trim()
: null,
addressLine1:
input.addressLine1 && input.addressLine1.trim() !== ""
? input.addressLine1.trim()
: null,
addressLine2:
input.addressLine2 && input.addressLine2.trim() !== ""
? input.addressLine2.trim()
: null,
city:
input.city && input.city.trim() !== "" ? input.city.trim() : null,
state:
input.state && input.state.trim() !== ""
? input.state.trim()
: null,
postalCode:
input.postalCode && input.postalCode.trim() !== ""
? input.postalCode.trim()
: null,
country:
input.country && input.country.trim() !== ""
? input.country.trim()
: null,
website:
input.website && input.website.trim() !== ""
? input.website.trim()
: null,
taxId:
input.taxId && input.taxId.trim() !== ""
? input.taxId.trim()
: null,
logoUrl:
input.logoUrl && input.logoUrl.trim() !== ""
? input.logoUrl.trim()
: null,
isDefault: input.isDefault ?? false,
createdById: ctx.session.user.id, createdById: ctx.session.user.id,
}) })
.returning(); .returning();
@@ -126,7 +183,56 @@ export const businessesRouter = createTRPCRouter({
const [updatedBusiness] = await ctx.db const [updatedBusiness] = await ctx.db
.update(businesses) .update(businesses)
.set({ .set({
...updateData, name: (updateData.name ?? "").trim(),
nickname:
updateData.nickname && updateData.nickname.trim() !== ""
? updateData.nickname.trim()
: null,
email:
updateData.email && updateData.email.trim() !== ""
? updateData.email.trim()
: null,
phone:
updateData.phone && updateData.phone.trim() !== ""
? updateData.phone.trim()
: null,
addressLine1:
updateData.addressLine1 && updateData.addressLine1.trim() !== ""
? updateData.addressLine1.trim()
: null,
addressLine2:
updateData.addressLine2 && updateData.addressLine2.trim() !== ""
? updateData.addressLine2.trim()
: null,
city:
updateData.city && updateData.city.trim() !== ""
? updateData.city.trim()
: null,
state:
updateData.state && updateData.state.trim() !== ""
? updateData.state.trim()
: null,
postalCode:
updateData.postalCode && updateData.postalCode.trim() !== ""
? updateData.postalCode.trim()
: null,
country:
updateData.country && updateData.country.trim() !== ""
? updateData.country.trim()
: null,
website:
updateData.website && updateData.website.trim() !== ""
? updateData.website.trim()
: null,
taxId:
updateData.taxId && updateData.taxId.trim() !== ""
? updateData.taxId.trim()
: null,
logoUrl:
updateData.logoUrl && updateData.logoUrl.trim() !== ""
? updateData.logoUrl.trim()
: null,
isDefault: updateData.isDefault ?? false,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where( .where(

View File

@@ -77,7 +77,7 @@ export const emailRouter = createTRPCRouter({
// Create email content // Create email content
const subject = const subject =
input.customSubject ?? input.customSubject ??
`Invoice ${invoice.invoiceNumber} from ${invoice.business?.name ?? "Your Business"}`; `Invoice ${invoice.invoiceNumber} from ${invoice.business ? `${invoice.business.name}${invoice.business.nickname ? ` (${invoice.business.nickname})` : ""}` : "Your Business"}`;
const userName = const userName =
invoice.business?.emailFromName ?? invoice.business?.emailFromName ??
@@ -124,7 +124,11 @@ export const emailRouter = createTRPCRouter({
// Use business's custom Resend setup // Use business's custom Resend setup
resendInstance = new Resend(invoice.business.resendApiKey); resendInstance = new Resend(invoice.business.resendApiKey);
const fromName = const fromName =
invoice.business.emailFromName ?? invoice.business.name ?? userName; invoice.business.emailFromName ??
(invoice.business.nickname
? `${invoice.business.name} (${invoice.business.nickname})`
: invoice.business.name) ??
userName;
fromEmail = `${fromName} <noreply@${invoice.business.resendDomain}>`; fromEmail = `${fromName} <noreply@${invoice.business.resendDomain}>`;
} else if (env.RESEND_DOMAIN) { } else if (env.RESEND_DOMAIN) {
// Use system Resend configuration // Use system Resend configuration

View File

@@ -25,6 +25,7 @@ const ClientBackupSchema = z.object({
const BusinessBackupSchema = z.object({ const BusinessBackupSchema = z.object({
name: z.string(), name: z.string(),
nickname: z.string().optional(),
email: z.string().optional(), email: z.string().optional(),
phone: z.string().optional(), phone: z.string().optional(),
addressLine1: z.string().optional(), addressLine1: z.string().optional(),
@@ -51,6 +52,7 @@ const InvoiceItemBackupSchema = z.object({
const InvoiceBackupSchema = z.object({ const InvoiceBackupSchema = z.object({
invoiceNumber: z.string(), invoiceNumber: z.string(),
businessName: z.string().optional(), businessName: z.string().optional(),
businessNickname: z.string().optional(),
clientName: z.string(), clientName: z.string(),
issueDate: z.string().transform((str) => new Date(str)), issueDate: z.string().transform((str) => new Date(str)),
dueDate: z.string().transform((str) => new Date(str)), dueDate: z.string().transform((str) => new Date(str)),
@@ -205,6 +207,7 @@ export const settingsRouter = createTRPCRouter({
columns: { columns: {
id: true, id: true,
name: true, name: true,
nickname: true,
email: true, email: true,
phone: true, phone: true,
addressLine1: true, addressLine1: true,
@@ -232,6 +235,7 @@ export const settingsRouter = createTRPCRouter({
business: { business: {
columns: { columns: {
name: true, name: true,
nickname: true,
}, },
}, },
items: { items: {
@@ -269,6 +273,7 @@ export const settingsRouter = createTRPCRouter({
})), })),
businesses: userBusinesses.map((business) => ({ businesses: userBusinesses.map((business) => ({
name: business.name, name: business.name,
nickname: business.nickname ?? undefined,
email: business.email ?? undefined, email: business.email ?? undefined,
phone: business.phone ?? undefined, phone: business.phone ?? undefined,
addressLine1: business.addressLine1 ?? undefined, addressLine1: business.addressLine1 ?? undefined,
@@ -285,6 +290,7 @@ export const settingsRouter = createTRPCRouter({
invoices: userInvoices.map((invoice) => ({ invoices: userInvoices.map((invoice) => ({
invoiceNumber: invoice.invoiceNumber, invoiceNumber: invoice.invoiceNumber,
businessName: invoice.business?.name, businessName: invoice.business?.name,
businessNickname: invoice.business?.nickname,
clientName: invoice.client.name, clientName: invoice.client.name,
issueDate: invoice.issueDate, issueDate: invoice.issueDate,
dueDate: invoice.dueDate, dueDate: invoice.dueDate,
@@ -337,6 +343,9 @@ export const settingsRouter = createTRPCRouter({
if (newBusiness) { if (newBusiness) {
businessIdMap.set(businessData.name, newBusiness.id); businessIdMap.set(businessData.name, newBusiness.id);
if (businessData.nickname) {
businessIdMap.set(businessData.nickname, newBusiness.id);
}
} }
} }
@@ -347,9 +356,14 @@ export const settingsRouter = createTRPCRouter({
throw new Error(`Client ${invoiceData.clientName} not found`); throw new Error(`Client ${invoiceData.clientName} not found`);
} }
const businessId = invoiceData.businessName const businessId = invoiceData.businessNickname
? businessIdMap.get(invoiceData.businessName) ? (businessIdMap.get(invoiceData.businessNickname) ??
: null; (invoiceData.businessName
? (businessIdMap.get(invoiceData.businessName) ?? null)
: null))
: invoiceData.businessName
? (businessIdMap.get(invoiceData.businessName) ?? null)
: null;
const [newInvoice] = await tx const [newInvoice] = await tx
.insert(invoices) .insert(invoices)

View File

@@ -143,6 +143,7 @@ export const businesses = createTable(
.primaryKey() .primaryKey()
.$defaultFn(() => crypto.randomUUID()), .$defaultFn(() => crypto.randomUUID()),
name: d.varchar({ length: 255 }).notNull(), name: d.varchar({ length: 255 }).notNull(),
nickname: d.varchar({ length: 255 }),
email: d.varchar({ length: 255 }), email: d.varchar({ length: 255 }),
phone: d.varchar({ length: 50 }), phone: d.varchar({ length: 50 }),
addressLine1: d.varchar({ length: 255 }), addressLine1: d.varchar({ length: 255 }),
@@ -172,6 +173,7 @@ export const businesses = createTable(
(t) => [ (t) => [
index("business_created_by_idx").on(t.createdById), index("business_created_by_idx").on(t.createdById),
index("business_name_idx").on(t.name), index("business_name_idx").on(t.name),
index("business_nickname_idx").on(t.nickname),
index("business_email_idx").on(t.email), index("business_email_idx").on(t.email),
index("business_is_default_idx").on(t.isDefault), index("business_is_default_idx").on(t.isDefault),
], ],

View File

@@ -26,6 +26,7 @@ export interface InvoiceWithRelations extends Invoice {
business: { business: {
id: string; id: string;
name: string; name: string;
nickname: string | null;
email: string | null; email: string | null;
} | null; } | null;
invoiceItems: Array<{ invoiceItems: Array<{