Refactor import page and add password change feature

This commit overhauls the invoice import UI and adds password
management. The changes:

- Replace custom import UI with reusable CSVImportPage component
- Add password change functionality with validation
- Improve form styling and accessibility
- Update import instructions for simplified CSV format
- Add client selection and validation
This commit is contained in:
2025-07-16 14:40:15 -04:00
parent 1023bc0c2b
commit 5f02bc1ff3
5 changed files with 468 additions and 453 deletions

View File

@@ -208,7 +208,7 @@ export default async function ClientDetailPage({
{/* Recent Invoices */} {/* Recent Invoices */}
{client.invoices && client.invoices.length > 0 && ( {client.invoices && client.invoices.length > 0 && (
<Card className="card-primary"> <Card className="">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<div className="bg-blue-subtle rounded-lg p-2"> <div className="bg-blue-subtle rounded-lg p-2">
@@ -222,7 +222,7 @@ export default async function ClientDetailPage({
{client.invoices.slice(0, 3).map((invoice) => ( {client.invoices.slice(0, 3).map((invoice) => (
<div <div
key={invoice.id} key={invoice.id}
className="flex items-center justify-between rounded-lg border p-3" className="card-secondary transition-colors hover:bg-gray-200/70 dark:hover:bg-gray-700/60 flex items-center justify-between rounded-lg border p-3"
> >
<div> <div>
<p className="text-foreground font-medium"> <p className="text-foreground font-medium">

View File

@@ -4,8 +4,8 @@ import { HydrateClient } from "~/trpc/server";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Separator } from "~/components/ui/separator";
import { PageHeader } from "~/components/layout/page-header"; import { PageHeader } from "~/components/layout/page-header";
import { CSVImportPage } from "~/components/csv-import-page";
import { import {
ArrowLeft, ArrowLeft,
Upload, Upload,
@@ -14,133 +14,10 @@ import {
CheckCircle, CheckCircle,
AlertCircle, AlertCircle,
Info, Info,
Zap,
FileSpreadsheet, FileSpreadsheet,
Eye,
RefreshCw,
} from "lucide-react"; } from "lucide-react";
// Import Statistics Component // File Upload Instructions Component
function ImportStats() {
const stats = [
{
title: "Supported Formats",
value: "CSV",
icon: FileSpreadsheet,
color: "text-blue-600",
bgColor: "bg-blue-50 dark:bg-blue-900/20",
description: "Excel & Google Sheets exports",
},
{
title: "Max File Size",
value: "10MB",
icon: Upload,
color: "text-green-600",
bgColor: "bg-green-50 dark:bg-green-900/20",
description: "Up to 1000 invoices",
},
{
title: "Processing Time",
value: "< 1min",
icon: Zap,
color: "text-purple-600",
bgColor: "bg-purple-50 dark:bg-purple-900/20",
description: "Average processing speed",
},
{
title: "Success Rate",
value: "99.9%",
icon: CheckCircle,
color: "text-emerald-600",
bgColor: "bg-emerald-50 dark:bg-emerald-900/20",
description: "Import success rate",
},
];
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{stats.map((stat) => {
const Icon = stat.icon;
return (
<Card
key={stat.title}
className="card-primary transition-shadow hover:shadow-lg"
>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-2">
<p className="text-muted-foreground text-sm font-medium">
{stat.title}
</p>
<p className="text-2xl font-bold">{stat.value}</p>
<p className="text-muted-foreground text-xs">
{stat.description}
</p>
</div>
<div className={`rounded-full p-3 ${stat.bgColor}`}>
<Icon className={`h-6 w-6 ${stat.color}`} />
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
);
}
// File Upload Component
function FileUploadArea() {
return (
<Card className="card-primary">
<CardHeader className="border-b">
<CardTitle className="card-title-secondary">
<Upload className="text-icon-emerald h-5 w-5" />
Upload CSV File
</CardTitle>
</CardHeader>
<CardContent className="p-8">
<div className="mx-auto max-w-xl">
{/* Drop Zone */}
<div className="bg-upload-zone">
<div className="bg-brand-muted mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full">
<Upload className="text-icon-emerald h-8 w-8" />
</div>
<h3 className="mb-2 text-lg font-semibold">
Drop your CSV file here
</h3>
<p className="text-muted-foreground mb-4">
or click to browse and select a file
</p>
<Button type="button" className="btn-brand-primary">
<Upload className="mr-2 h-4 w-4" />
Choose File
</Button>
<p className="text-muted-foreground mt-4 text-sm">
Maximum file size: 10MB Supported format: CSV
</p>
</div>
{/* Upload Progress (hidden by default) */}
<div className="mt-6 hidden">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium">Uploading...</span>
<span className="text-icon-emerald text-sm">75%</span>
</div>
<div className="bg-progress-track">
<div
className="bg-brand-gradient h-full transition-all duration-300"
style={{ width: "75%" }}
></div>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
// CSV Format Instructions
function FormatInstructions() { function FormatInstructions() {
return ( return (
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
@@ -155,7 +32,7 @@ function FormatInstructions() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="bg-muted-subtle rounded-lg p-4"> <div className="bg-muted-subtle rounded-lg p-4">
<p className="text-secondary font-mono text-sm"> <p className="text-secondary font-mono text-sm">
client_name,client_email,invoice_number,issue_date,due_date,description,hours,rate,tax_rate DATE,DESCRIPTION,HOURS,RATE,AMOUNT
</p> </p>
</div> </div>
@@ -163,14 +40,14 @@ function FormatInstructions() {
<h4 className="font-semibold">Required Columns:</h4> <h4 className="font-semibold">Required Columns:</h4>
<div className="grid gap-2"> <div className="grid gap-2">
{[ {[
{ field: "client_name", desc: "Full name of the client" }, { field: "DATE", desc: "Date of work (M/DD/YY format)" },
{ field: "client_email", desc: "Client email address" }, { field: "DESCRIPTION", desc: "Description of work performed" },
{ field: "invoice_number", desc: "Unique invoice identifier" }, { field: "HOURS", desc: "Number of hours worked" },
{ field: "issue_date", desc: "Date issued (YYYY-MM-DD)" }, { field: "RATE", desc: "Hourly rate (decimal)" },
{ field: "due_date", desc: "Payment due date (YYYY-MM-DD)" }, {
{ field: "description", desc: "Work description" }, field: "AMOUNT",
{ field: "hours", desc: "Number of hours worked" }, desc: "Total amount (calculated from hours × rate)",
{ field: "rate", desc: "Hourly rate (decimal)" }, },
].map((col) => ( ].map((col) => (
<div key={col.field} className="flex items-start gap-3"> <div key={col.field} className="flex items-start gap-3">
<Badge className="badge-outline text-xs">{col.field}</Badge> <Badge className="badge-outline text-xs">{col.field}</Badge>
@@ -183,12 +60,14 @@ function FormatInstructions() {
</div> </div>
<div className="pt-2"> <div className="pt-2">
<h4 className="mb-2 font-semibold">Optional Columns:</h4> <h4 className="mb-2 font-semibold">File Naming:</h4>
<div className="flex flex-wrap gap-2"> <p className="text-muted-foreground text-sm">
<Badge className="badge-secondary text-xs">tax_rate</Badge> Name your CSV files in{" "}
<Badge className="badge-secondary text-xs">notes</Badge> <code className="bg-muted rounded px-1 text-xs">
<Badge className="badge-secondary text-xs">client_phone</Badge> YYYY-MM-DD.csv
</div> </code>{" "}
format for automatic date detection.
</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -204,7 +83,7 @@ function FormatInstructions() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Download our sample CSV template to see the exact format required Download our sample CSV template to see the exact format required
for importing invoices. for importing time entries.
</p> </p>
<div className="bg-green-subtle rounded-lg p-4"> <div className="bg-green-subtle rounded-lg p-4">
@@ -220,31 +99,21 @@ function FormatInstructions() {
</div> </div>
</div> </div>
<div className="space-y-3">
<Button variant="outline" className="w-full justify-start">
<Download className="mr-2 h-4 w-4" />
Download Sample CSV Template
</Button>
<Button variant="outline" className="w-full justify-start">
<Eye className="mr-2 h-4 w-4" />
View Template in Browser
</Button>
</div>
<Separator />
<div className="space-y-2"> <div className="space-y-2">
<h4 className="text-sm font-semibold">Sample Row:</h4> <h4 className="text-sm font-semibold">Sample Row:</h4>
<div className="bg-muted-subtle rounded-lg p-3"> <div className="bg-muted-subtle rounded-lg p-3">
<p className="text-muted font-mono text-xs break-all"> <p className="text-muted font-mono text-xs break-all">
&quot;Acme 1/15/24,&quot;Web development work&quot;,8,75.00,600.00
Corp&quot;,&quot;john@acme.com&quot;,&quot;INV-001&quot;,&quot;2024-01-15&quot;,&quot;2024-02-14&quot;,&quot;Web
development
work&quot;,&quot;40&quot;,&quot;75.00&quot;,&quot;8.5&quot;
</p> </p>
</div> </div>
</div> </div>
<div className="space-y-2">
<h4 className="text-sm font-semibold">Sample Filename:</h4>
<div className="bg-muted-subtle rounded-lg p-3">
<p className="text-muted font-mono text-xs">2024-01-15.csv</p>
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -266,18 +135,18 @@ function ImportantNotes() {
<div> <div>
<h4 className="mb-2 font-semibold">Before Importing:</h4> <h4 className="mb-2 font-semibold">Before Importing:</h4>
<ul className="text-muted-foreground space-y-1 text-sm"> <ul className="text-muted-foreground space-y-1 text-sm">
<li> Ensure all client emails are valid</li> <li> Use M/DD/YY format for dates (e.g., 1/15/24)</li>
<li> Use YYYY-MM-DD format for dates</li> <li> Ensure rates are in decimal format (e.g., 75.50)</li>
<li> Invoice numbers must be unique</li> <li> File names should follow YYYY-MM-DD.csv format</li>
<li> Rates should be in decimal format (e.g., 75.50)</li> <li> Select a client before importing</li>
</ul> </ul>
</div> </div>
<div> <div>
<h4 className="mb-2 font-semibold">What Happens:</h4> <h4 className="mb-2 font-semibold">What Happens:</h4>
<ul className="text-muted-foreground space-y-1 text-sm"> <ul className="text-muted-foreground space-y-1 text-sm">
<li> New clients will be created automatically</li> <li> Each CSV file creates one invoice</li>
<li> Existing clients will be matched by email</li> <li> Invoice dates are derived from filename</li>
<li> Invoices will be created in &quot;draft&quot; status</li> <li> Invoices are created in &quot;draft&quot; status</li>
<li> You can review and edit before sending</li> <li> You can review and edit before sending</li>
</ul> </ul>
</div> </div>
@@ -287,124 +156,47 @@ function ImportantNotes() {
); );
} }
// Import History Component // File Format Help Section
function ImportHistory() { function FileFormatHelp() {
const mockHistory = [
{
id: "1",
filename: "january_invoices.csv",
date: "2024-01-15",
status: "completed",
imported: 25,
errors: 0,
},
{
id: "2",
filename: "december_invoices.csv",
date: "2024-01-01",
status: "completed",
imported: 18,
errors: 2,
},
{
id: "3",
filename: "november_invoices.csv",
date: "2023-12-01",
status: "completed",
imported: 32,
errors: 1,
},
];
const getStatusBadge = (status: string) => {
if (status === "completed") {
return (
<Badge className="badge-success">
<CheckCircle className="mr-1 h-3 w-3" />
Completed
</Badge>
);
}
if (status === "processing") {
return (
<Badge className="badge-features">
<RefreshCw className="mr-1 h-3 w-3" />
Processing
</Badge>
);
}
return (
<Badge variant="outline">
<AlertCircle className="mr-1 h-3 w-3" />
Failed
</Badge>
);
};
return ( return (
<Card className="card-primary"> <Card className="card-primary">
<CardHeader> <CardHeader>
<CardTitle className="card-title-purple"> <CardTitle className="card-title-info">
<FileText className="text-icon-purple h-5 w-5" /> <FileSpreadsheet className="text-icon-blue h-5 w-5" />
Recent Imports Supported File Formats
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="space-y-4">
<div className="overflow-x-auto"> <div className="grid gap-4 md:grid-cols-3">
<table className="w-full"> <div className="space-y-2 text-center">
<thead className="bg-muted/50"> <div className="mx-auto w-fit rounded-full bg-blue-50 p-3 dark:bg-blue-900/20">
<tr className="border-b"> <FileSpreadsheet className="h-6 w-6 text-blue-600" />
<th className="p-4 text-left text-sm font-medium">File</th> </div>
<th className="p-4 text-left text-sm font-medium">Date</th> <h4 className="font-semibold">CSV Files</h4>
<th className="p-4 text-left text-sm font-medium">Status</th> <p className="text-muted-foreground text-sm">
<th className="p-4 text-right text-sm font-medium">Imported</th> Comma-separated values from Excel, Google Sheets, or any CSV
<th className="p-4 text-right text-sm font-medium">Errors</th> editor
<th className="p-4 text-center text-sm font-medium">Actions</th> </p>
</tr>
</thead>
<tbody>
{mockHistory.map((item) => (
<tr
key={item.id}
className="hover:bg-muted/20 border-b transition-colors"
>
<td className="p-4">
<div className="flex items-center gap-3">
<div className="icon-bg-purple-muted">
<FileSpreadsheet className="text-icon-purple h-4 w-4" />
</div>
<span className="font-medium">{item.filename}</span>
</div>
</td>
<td className="p-4 text-sm">
{new Date(item.date).toLocaleDateString()}
</td>
<td className="p-4">{getStatusBadge(item.status)}</td>
<td className="p-4 text-right font-medium">
{item.imported}
</td>
<td className="p-4 text-right">
{item.errors > 0 ? (
<span className="status-text-error">{item.errors}</span>
) : (
<span className="text-muted-foreground">0</span>
)}
</td>
<td className="p-4 text-center">
<Button variant="ghost" size="sm">
<Eye className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{mockHistory.length === 0 && (
<div className="py-8 text-center">
<p className="text-muted-foreground">No import history yet</p>
</div> </div>
)} <div className="space-y-2 text-center">
<div className="mx-auto w-fit rounded-full bg-green-50 p-3 dark:bg-green-900/20">
<Upload className="h-6 w-6 text-green-600" />
</div>
<h4 className="font-semibold">Max Size</h4>
<p className="text-muted-foreground text-sm">
Up to 10MB per file with no limit on number of rows
</p>
</div>
<div className="space-y-2 text-center">
<div className="mx-auto w-fit rounded-full bg-purple-50 p-3 dark:bg-purple-900/20">
<CheckCircle className="h-6 w-6 text-purple-600" />
</div>
<h4 className="font-semibold">Validation</h4>
<p className="text-muted-foreground text-sm">
Real-time validation with clear error messages and feedback
</p>
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -414,8 +206,8 @@ export default async function ImportPage() {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<PageHeader <PageHeader
title="Import Invoices" title="Import Time Entries"
description="Upload CSV files to create invoices in batch" description="Upload CSV files to create invoices from your time tracking data"
variant="gradient" variant="gradient"
> >
<Link href="/dashboard/invoices"> <Link href="/dashboard/invoices">
@@ -427,33 +219,17 @@ export default async function ImportPage() {
</PageHeader> </PageHeader>
<HydrateClient> <HydrateClient>
<Suspense {/* Main CSV Import Component */}
fallback={ <CSVImportPage />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }, (_, i) => (
<Card key={i} className="card-primary">
<CardContent className="p-6">
<div className="animate-pulse">
<div className="bg-muted mb-2 h-4 w-1/2 rounded"></div>
<div className="bg-muted mb-2 h-8 w-3/4 rounded"></div>
<div className="bg-muted h-3 w-1/3 rounded"></div>
</div>
</CardContent>
</Card>
))}
</div>
}
>
<ImportStats />
</Suspense>
<FileUploadArea /> {/* File Format Help */}
<FileFormatHelp />
{/* Format Instructions */}
<FormatInstructions /> <FormatInstructions />
{/* Important Notes */}
<ImportantNotes /> <ImportantNotes />
<ImportHistory />
</HydrateClient> </HydrateClient>
</div> </div>
); );

View File

@@ -10,10 +10,12 @@ import {
Database, Database,
AlertTriangle, AlertTriangle,
Shield, Shield,
Settings,
FileText, FileText,
Users, Users,
Building, Building,
Key,
Eye,
EyeOff,
} from "lucide-react"; } from "lucide-react";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
@@ -58,6 +60,14 @@ export function SettingsContent() {
const [importData, setImportData] = useState(""); const [importData, setImportData] = useState("");
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false); const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
// Password change state
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
const [showNewPassword, setShowNewPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
// Queries // Queries
const { data: profile, refetch: refetchProfile } = const { data: profile, refetch: refetchProfile } =
api.settings.getProfile.useQuery(); api.settings.getProfile.useQuery();
@@ -74,6 +84,18 @@ export function SettingsContent() {
}, },
}); });
const changePasswordMutation = api.settings.changePassword.useMutation({
onSuccess: () => {
toast.success("Password changed successfully");
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
},
onError: (error: { message: string }) => {
toast.error(`Failed to change password: ${error.message}`);
},
});
const exportDataQuery = api.settings.exportData.useQuery(undefined, { const exportDataQuery = api.settings.exportData.useQuery(undefined, {
enabled: false, enabled: false,
}); });
@@ -134,6 +156,27 @@ export function SettingsContent() {
updateProfileMutation.mutate({ name: name.trim() }); updateProfileMutation.mutate({ name: name.trim() });
}; };
const handleChangePassword = (e: React.FormEvent) => {
e.preventDefault();
if (!currentPassword || !newPassword || !confirmPassword) {
toast.error("Please fill in all password fields");
return;
}
if (newPassword !== confirmPassword) {
toast.error("New passwords don't match");
return;
}
if (newPassword.length < 8) {
toast.error("New password must be at least 8 characters");
return;
}
changePasswordMutation.mutate({
currentPassword,
newPassword,
confirmPassword,
});
};
const handleExportData = () => { const handleExportData = () => {
void exportDataQuery.refetch(); void exportDataQuery.refetch();
}; };
@@ -239,21 +282,21 @@ export function SettingsContent() {
value: dataStats?.clients ?? 0, value: dataStats?.clients ?? 0,
icon: Users, icon: Users,
color: "text-blue-600", color: "text-blue-600",
bgColor: "bg-blue-100", bgColor: "bg-blue-50 dark:bg-blue-900/20",
}, },
{ {
label: "Businesses", label: "Businesses",
value: dataStats?.businesses ?? 0, value: dataStats?.businesses ?? 0,
icon: Building, icon: Building,
color: "text-purple-600", color: "text-purple-600",
bgColor: "bg-purple-100", bgColor: "bg-purple-50 dark:bg-purple-900/20",
}, },
{ {
label: "Invoices", label: "Invoices",
value: dataStats?.invoices ?? 0, value: dataStats?.invoices ?? 0,
icon: FileText, icon: FileText,
color: "text-emerald-600", color: "text-emerald-600",
bgColor: "bg-emerald-100", bgColor: "bg-emerald-50 dark:bg-emerald-900/20",
}, },
]; ];
@@ -264,10 +307,8 @@ export function SettingsContent() {
{/* Profile Section */} {/* Profile Section */}
<Card className="card-primary"> <Card className="card-primary">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="card-title-secondary">
<div className="icon-bg-emerald"> <User className="text-icon-blue h-5 w-5" />
<User className="text-icon-emerald h-5 w-5" />
</div>
Profile Information Profile Information
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
@@ -283,7 +324,6 @@ export function SettingsContent() {
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="Enter your full name" placeholder="Enter your full name"
className="border-0 shadow-sm"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -292,7 +332,7 @@ export function SettingsContent() {
id="email" id="email"
value={session?.user?.email ?? ""} value={session?.user?.email ?? ""}
disabled disabled
className="bg-muted border-0 shadow-sm" className="bg-muted"
/> />
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
Email address cannot be changed Email address cannot be changed
@@ -314,10 +354,8 @@ export function SettingsContent() {
{/* Data Overview */} {/* Data Overview */}
<Card className="card-primary"> <Card className="card-primary">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="card-title-info">
<div className="icon-bg-info"> <Database className="text-icon-blue h-5 w-5" />
<Database className="text-icon-blue h-5 w-5" />
</div>
Account Data Account Data
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
@@ -329,24 +367,23 @@ export function SettingsContent() {
{dataStatItems.map((item) => { {dataStatItems.map((item) => {
const Icon = item.icon; const Icon = item.icon;
return ( return (
<Card key={item.label} className="card-secondary"> <div
<CardContent className="py-2"> key={item.label}
<div className="flex items-center justify-between"> className="bg-card flex items-center justify-between rounded-lg border p-4 transition-shadow hover:shadow-sm"
<div className="flex items-center gap-3"> >
<div className={`rounded-lg p-2 ${item.bgColor}`}> <div className="flex items-center gap-3">
<Icon className={`h-4 w-4 ${item.color}`} /> <div className={`rounded-lg p-2 ${item.bgColor}`}>
</div> <Icon className={`h-4 w-4 ${item.color}`} />
<span className="font-medium">{item.label}</span>
</div>
<Badge
variant="secondary"
className="text-lg font-semibold"
>
{item.value}
</Badge>
</div> </div>
</CardContent> <span className="font-medium">{item.label}</span>
</Card> </div>
<Badge
variant="secondary"
className="text-lg font-semibold"
>
{item.value}
</Badge>
</div>
); );
})} })}
</div> </div>
@@ -354,13 +391,118 @@ export function SettingsContent() {
</Card> </Card>
</div> </div>
{/* Security Settings */}
<Card className="card-primary">
<CardHeader>
<CardTitle className="card-title-secondary">
<Key className="text-icon-amber h-5 w-5" />
Security Settings
</CardTitle>
<CardDescription>
Change your password and manage account security
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleChangePassword} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current-password">Current Password</Label>
<div className="relative">
<Input
id="current-password"
type={showCurrentPassword ? "text" : "password"}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="Enter your current password"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute top-1/2 right-2 h-6 w-6 -translate-y-1/2 p-0"
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
>
{showCurrentPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">New Password</Label>
<div className="relative">
<Input
id="new-password"
type={showNewPassword ? "text" : "password"}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Enter your new password"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute top-1/2 right-2 h-6 w-6 -translate-y-1/2 p-0"
onClick={() => setShowNewPassword(!showNewPassword)}
>
{showNewPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
<p className="text-muted-foreground text-sm">
Password must be at least 8 characters long
</p>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm New Password</Label>
<div className="relative">
<Input
id="confirm-password"
type={showConfirmPassword ? "text" : "password"}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm your new password"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute top-1/2 right-2 h-6 w-6 -translate-y-1/2 p-0"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
</div>
<Button
type="submit"
disabled={changePasswordMutation.isPending}
className="btn-brand-primary"
>
{changePasswordMutation.isPending
? "Changing Password..."
: "Change Password"}
</Button>
</form>
</CardContent>
</Card>
{/* Data Management */} {/* Data Management */}
<Card className="card-primary"> <Card className="card-primary">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="card-title-secondary">
<div className="bg-indigo-subtle rounded-lg p-2"> <Shield className="text-icon-indigo h-5 w-5" />
<Shield className="text-icon-indigo h-5 w-5" />
</div>
Data Management Data Management
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
@@ -429,38 +571,38 @@ export function SettingsContent() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
</div>
{/* Backup Information */} {/* Backup Information */}
<div className="mt-6 rounded-lg border p-4"> <div className="border-border bg-muted/20 rounded-lg border p-4">
<h4 className="font-medium">Backup Information</h4> <h4 className="font-medium">Backup Information</h4>
<ul className="text-muted-foreground mt-2 space-y-1 text-sm"> <ul className="text-muted-foreground mt-2 space-y-1 text-sm">
<li> Regular backups protect your important business data</li> <li> Regular backups protect your important business data</li>
<li> Backup files contain all data in secure JSON format</li> <li> Backup files contain all data in secure JSON format</li>
<li> Import adds to existing data without replacing anything</li> <li>
<li> Store backup files in a secure, accessible location</li> Import adds to existing data without replacing anything
</ul> </li>
<li> Store backup files in a secure, accessible location</li>
</ul>
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* Danger Zone */} {/* Danger Zone */}
<Card className="card-primary"> <Card className="card-primary border-l-4 border-l-red-500">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="card-title-warning">
<div className="icon-bg-error"> <AlertTriangle className="text-icon-red h-5 w-5" />
<AlertTriangle className="text-icon-red h-5 w-5" /> Danger Zone
</div>
Data Management
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Manage your account data with caution Irreversible actions that permanently affect your account
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-lg border p-4"> <div className="rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20">
<h4 className="font-medium text-red-600"> <h4 className="font-medium text-red-600 dark:text-red-400">
Delete All Account Data Delete All Account Data
</h4> </h4>
<p className="text-muted-foreground mt-2 text-sm"> <p className="text-muted-foreground mt-2 text-sm">
@@ -472,7 +614,7 @@ export function SettingsContent() {
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="destructive" className="w-100"> <Button variant="destructive" className="w-full">
<AlertTriangle className="mr-2 h-4 w-4" /> <AlertTriangle className="mr-2 h-4 w-4" />
Delete All Data Delete All Data
</Button> </Button>
@@ -485,7 +627,7 @@ export function SettingsContent() {
This action cannot be undone. This will permanently delete This action cannot be undone. This will permanently delete
all your: all your:
</div> </div>
<ul className="list-inside list-disc space-y-1 rounded-lg border p-3 text-sm"> <ul className="border-border bg-muted/50 list-inside list-disc space-y-1 rounded-lg border p-3 text-sm">
<li>Client information and contact details</li> <li>Client information and contact details</li>
<li>Business profiles and settings</li> <li>Business profiles and settings</li>
<li>Invoices and invoice line items</li> <li>Invoices and invoice line items</li>
@@ -516,7 +658,7 @@ export function SettingsContent() {
deleteConfirmText !== "delete all my data" || deleteConfirmText !== "delete all my data" ||
deleteDataMutation.isPending deleteDataMutation.isPending
} }
className="btn-danger" className="bg-red-600 hover:bg-red-700"
> >
{deleteDataMutation.isPending {deleteDataMutation.isPending
? "Deleting..." ? "Deleting..."

View File

@@ -440,8 +440,8 @@ export function CSVImportPage() {
{/* Global Client Selection */} {/* Global Client Selection */}
<Card className="card-primary"> <Card className="card-primary">
<CardHeader> <CardHeader>
<CardTitle className="card-title-primary"> <CardTitle className="card-title-secondary">
<Users className="h-5 w-5" /> <Users className="text-icon-blue h-5 w-5" />
Default Client Default Client
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@@ -460,7 +460,7 @@ export function CSVImportPage() {
applyGlobalClient(newClientId); applyGlobalClient(newClientId);
} }
}} }}
className="h-12 w-full rounded-md border px-3 py-2" className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-12 w-full rounded-md border px-3 py-2 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
disabled={loadingClients} disabled={loadingClients}
> >
<option value="">No default client (select individually)</option> <option value="">No default client (select individually)</option>
@@ -470,7 +470,7 @@ export function CSVImportPage() {
</option> </option>
))} ))}
</select> </select>
<p className="text-muted text-xs"> <p className="text-muted-foreground text-xs">
This client will be automatically selected for all uploaded files. This client will be automatically selected for all uploaded files.
You can still change individual files below. You can still change individual files below.
</p> </p>
@@ -481,8 +481,8 @@ export function CSVImportPage() {
{/* File Upload Area */} {/* File Upload Area */}
<Card className="card-primary"> <Card className="card-primary">
<CardHeader> <CardHeader>
<CardTitle className="card-title-primary"> <CardTitle className="card-title-secondary">
<Upload className="h-5 w-5" /> <Upload className="text-icon-emerald h-5 w-5" />
Upload CSV Files Upload CSV Files
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@@ -496,38 +496,55 @@ export function CSVImportPage() {
description="Files must be named YYYY-MM-DD.csv (e.g., 2024-01-15.csv). Up to 50 files can be uploaded at once." description="Files must be named YYYY-MM-DD.csv (e.g., 2024-01-15.csv). Up to 50 files can be uploaded at once."
/> />
{/* Summary Stats */} {/* Summary Card */}
{totalFiles > 0 && ( {totalFiles > 0 && (
<div className="grid grid-cols-2 gap-4 rounded-lg bg-emerald-50/50 p-4 md:grid-cols-4"> <Card className="card-primary">
<div className="text-center"> <CardHeader>
<div className="text-icon-emerald text-2xl font-bold"> <CardTitle className="card-title-secondary">
{totalFiles} <FileText className="text-icon-emerald h-5 w-5" />
Import Summary
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 rounded-lg bg-green-50/50 p-4 md:grid-cols-4 dark:bg-green-900/10">
<div className="text-center">
<div className="text-2xl font-bold text-green-600">
{totalFiles}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
Files
</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">
{totalItems}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
Total Items
</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">
{totalAmount.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
Total Amount
</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">
{readyFiles}/{totalFiles}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
Ready
</div>
</div>
</div> </div>
<div className="text-secondary text-sm">Files</div> </CardContent>
<div className="text-muted text-xs">of 50 max</div> </Card>
</div>
<div className="text-center">
<div className="text-icon-emerald text-2xl font-bold">
{totalItems}
</div>
<div className="text-secondary text-sm">Total Items</div>
</div>
<div className="text-center">
<div className="text-icon-emerald text-2xl font-bold">
{totalAmount.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
</div>
<div className="text-secondary text-sm">Total Amount</div>
</div>
<div className="text-center">
<div className="text-icon-emerald text-2xl font-bold">
{readyFiles}/{totalFiles}
</div>
<div className="text-secondary text-sm">Ready</div>
</div>
</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@@ -536,23 +553,25 @@ export function CSVImportPage() {
{files.length > 0 && ( {files.length > 0 && (
<Card className="card-primary"> <Card className="card-primary">
<CardHeader> <CardHeader>
<CardTitle className="text-brand-light">Uploaded Files</CardTitle> <CardTitle className="card-title-secondary">
Uploaded Files
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{files.map((fileData, index) => ( {files.map((fileData, index) => (
<div <div
key={index} key={index}
className="rounded-lg border border-gray-200 bg-white p-4" className="border-border bg-card rounded-lg border p-4"
> >
<div className="mb-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div className="mb-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<FileText className="text-icon-emerald h-5 w-5" /> <FileText className="text-icon-emerald h-5 w-5" />
<div> <div>
<h3 className="text-accent truncate font-medium"> <h3 className="text-foreground truncate font-medium">
{fileData.file.name} {fileData.file.name}
</h3> </h3>
<p className="text-muted text-sm"> <p className="text-muted-foreground text-sm">
{fileData.parsedItems.length} items {" "} {fileData.parsedItems.length} items {" "}
{fileData.parsedItems {fileData.parsedItems
.reduce((sum, item) => sum + item.hours, 0) .reduce((sum, item) => sum + item.hours, 0)
@@ -574,7 +593,7 @@ export function CSVImportPage() {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => removeFile(index)} onClick={() => removeFile(index)}
className="text-icon-red hover:text-error" className="text-red-600 hover:text-red-700"
> >
<Trash2 className="mr-1 h-4 w-4" /> <Trash2 className="mr-1 h-4 w-4" />
Remove Remove
@@ -584,7 +603,7 @@ export function CSVImportPage() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-secondary text-xs font-medium"> <Label className="text-muted-foreground text-xs font-medium">
Invoice Number Invoice Number
</Label> </Label>
<Input <Input
@@ -596,15 +615,18 @@ export function CSVImportPage() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-medium">Client</Label> <Label className="text-muted-foreground text-xs font-medium">
Client
</Label>
<select <select
value={fileData.clientId} value={fileData.clientId}
onChange={(e) => onChange={(e) =>
updateFileData(index, { clientId: e.target.value }) updateFileData(index, { clientId: e.target.value })
} }
className="h-9 w-full rounded-md border px-3 py-1 text-sm" className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
disabled={loadingClients}
> >
<option value="">Select client</option> <option value="">Select Client</option>
{clients?.map((client) => ( {clients?.map((client) => (
<option key={client.id} value={client.id}> <option key={client.id} value={client.id}>
{client.name} {client.name}
@@ -614,7 +636,7 @@ export function CSVImportPage() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-secondary text-xs font-medium"> <Label className="text-muted-foreground text-xs font-medium">
Issue Date Issue Date
</Label> </Label>
<DatePicker <DatePicker
@@ -628,7 +650,7 @@ export function CSVImportPage() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-secondary text-xs font-medium"> <Label className="text-muted-foreground text-xs font-medium">
Due Date Due Date
</Label> </Label>
<DatePicker <DatePicker
@@ -644,20 +666,20 @@ export function CSVImportPage() {
{/* Error Display */} {/* Error Display */}
{fileData.errors.length > 0 && ( {fileData.errors.length > 0 && (
<div className="mt-4 rounded-lg border border-red-200 bg-red-50 p-3"> <div className="mt-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-900/20">
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<AlertCircle className="text-icon-red h-4 w-4" /> <AlertCircle className="h-4 w-4 text-red-600" />
<span className="text-error text-sm font-medium"> <span className="text-sm font-medium text-red-800 dark:text-red-200">
Issues Found Issues Found
</span> </span>
</div> </div>
<ul className="text-error space-y-1 text-sm"> <ul className="space-y-1 text-sm text-red-700 dark:text-red-300">
{fileData.errors.map((error, errorIndex) => ( {fileData.errors.map((error, errorIndex) => (
<li <li
key={errorIndex} key={errorIndex}
className="flex items-start gap-2" className="flex items-start gap-2"
> >
<span className="text-icon-red"></span> <span className="text-red-600"></span>
<span>{error}</span> <span>{error}</span>
</li> </li>
))} ))}
@@ -666,7 +688,7 @@ export function CSVImportPage() {
)} )}
<div className="mt-4 flex items-center justify-between"> <div className="mt-4 flex items-center justify-between">
<div className="text-sm text-gray-600"> <div className="text-muted-foreground text-sm">
Total:{" "} Total:{" "}
{fileData.parsedItems {fileData.parsedItems
.reduce((sum, item) => sum + item.amount, 0) .reduce((sum, item) => sum + item.amount, 0)
@@ -677,7 +699,7 @@ export function CSVImportPage() {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{fileData.errors.length > 0 && ( {fileData.errors.length > 0 && (
<Badge className="badge-error text-xs"> <Badge variant="destructive" className="text-xs">
{fileData.errors.length} Error {fileData.errors.length} Error
{fileData.errors.length !== 1 ? "s" : ""} {fileData.errors.length !== 1 ? "s" : ""}
</Badge> </Badge>
@@ -692,6 +714,7 @@ export function CSVImportPage() {
? "default" ? "default"
: "secondary" : "secondary"
} }
className="text-xs"
> >
{fileData.errors.length > 0 {fileData.errors.length > 0
? "Has Errors" ? "Has Errors"
@@ -713,20 +736,27 @@ export function CSVImportPage() {
{/* Batch Actions */} {/* Batch Actions */}
{files.length > 0 && ( {files.length > 0 && (
<Card className="card-primary"> <Card className="card-primary">
<CardHeader>
<CardTitle className="card-title-secondary">
<DollarSign className="text-icon-green h-5 w-5" />
Create Invoices
</CardTitle>
</CardHeader>
<CardContent> <CardContent>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{isProcessing && ( {isProcessing && (
<div className="flex w-full flex-col gap-2"> <div className="flex w-full flex-col gap-2">
<span className="text-xs text-gray-500"> <span className="text-muted-foreground text-sm">
Uploading invoices... Creating invoices... ({progressCount}/{totalFiles})
</span> </span>
<Progress <Progress
value={Math.round((progressCount / totalFiles) * 100)} value={Math.round((progressCount / totalFiles) * 100)}
className="h-2"
/> />
</div> </div>
)} )}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="text-sm text-gray-600"> <div className="text-muted-foreground text-sm">
{readyFiles} of {totalFiles} files ready for import {readyFiles} of {totalFiles} files ready for import
</div> </div>
<Button <Button
@@ -790,24 +820,24 @@ export function CSVImportPage() {
</div> </div>
<div className="min-h-0 flex-1 overflow-hidden"> <div className="min-h-0 flex-1 overflow-hidden">
<div className="max-h-96 overflow-y-auto"> <div className="p-0">
<div className="overflow-x-auto"> <div className="max-h-96 overflow-auto">
<table className="w-full min-w-[600px] text-sm"> <table className="w-full border-collapse">
<thead className="sticky top-0 bg-gray-50"> <thead className="bg-muted/50 sticky top-0">
<tr> <tr>
<th className="p-2 text-left font-medium whitespace-nowrap text-gray-700"> <th className="text-muted-foreground p-2 text-left font-medium">
Date Date
</th> </th>
<th className="p-2 text-left font-medium text-gray-700"> <th className="text-muted-foreground p-2 text-left font-medium">
Description Description
</th> </th>
<th className="p-2 text-right font-medium whitespace-nowrap text-gray-700"> <th className="text-muted-foreground p-2 text-right font-medium whitespace-nowrap">
Hours Hours
</th> </th>
<th className="p-2 text-right font-medium whitespace-nowrap text-gray-700"> <th className="text-muted-foreground p-2 text-right font-medium whitespace-nowrap">
Rate Rate
</th> </th>
<th className="p-2 text-right font-medium whitespace-nowrap text-gray-700"> <th className="text-muted-foreground p-2 text-right font-medium whitespace-nowrap">
Amount Amount
</th> </th>
</tr> </tr>
@@ -815,26 +845,23 @@ export function CSVImportPage() {
<tbody> <tbody>
{files[selectedFileIndex].parsedItems.map( {files[selectedFileIndex].parsedItems.map(
(item, index) => ( (item, index) => (
<tr <tr key={index} className="border-border border-b">
key={index} <td className="text-foreground p-2 whitespace-nowrap">
className="border-b border-gray-100"
>
<td className="p-2 whitespace-nowrap text-gray-600">
{item.date.toLocaleDateString()} {item.date.toLocaleDateString()}
</td> </td>
<td className="max-w-xs truncate p-2 text-gray-600"> <td className="text-foreground max-w-xs truncate p-2">
{item.description} {item.description}
</td> </td>
<td className="p-2 text-right whitespace-nowrap text-gray-600"> <td className="text-foreground p-2 text-right whitespace-nowrap">
{item.hours} {item.hours}
</td> </td>
<td className="p-2 text-right whitespace-nowrap text-gray-600"> <td className="text-foreground p-2 text-right whitespace-nowrap">
{item.rate.toLocaleString("en-US", { {item.rate.toLocaleString("en-US", {
style: "currency", style: "currency",
currency: "USD", currency: "USD",
})} })}
</td> </td>
<td className="text-secondary p-2 text-right font-medium whitespace-nowrap"> <td className="text-foreground p-2 text-right font-medium whitespace-nowrap">
{item.amount.toLocaleString("en-US", { {item.amount.toLocaleString("en-US", {
style: "currency", style: "currency",
currency: "USD", currency: "USD",

View File

@@ -1,12 +1,13 @@
import { z } from "zod"; import { z } from "zod";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import bcrypt from "bcryptjs";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { import {
users, users,
clients, clients,
businesses, businesses,
invoices, invoices,
invoiceItems invoiceItems,
} from "~/server/db/schema"; } from "~/server/db/schema";
// Validation schemas for backup data // Validation schemas for backup data
@@ -93,7 +94,7 @@ export const settingsRouter = createTRPCRouter({
.input( .input(
z.object({ z.object({
name: z.string().min(1, "Name is required"), name: z.string().min(1, "Name is required"),
}) }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
await ctx.db await ctx.db
@@ -106,6 +107,68 @@ export const settingsRouter = createTRPCRouter({
return { success: true }; return { success: true };
}), }),
// Change user password
changePassword: protectedProcedure
.input(
z
.object({
currentPassword: z.string().min(1, "Current password is required"),
newPassword: z
.string()
.min(8, "New password must be at least 8 characters"),
confirmPassword: z
.string()
.min(1, "Password confirmation is required"),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
}),
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
// Get the current user with password
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, userId),
columns: {
id: true,
password: true,
},
});
if (!user || !user.password) {
throw new Error("User not found or no password set");
}
// Verify current password
const isCurrentPasswordValid = await bcrypt.compare(
input.currentPassword,
user.password,
);
if (!isCurrentPasswordValid) {
throw new Error("Current password is incorrect");
}
// Hash the new password
const saltRounds = 12;
const hashedNewPassword = await bcrypt.hash(
input.newPassword,
saltRounds,
);
// Update the password
await ctx.db
.update(users)
.set({
password: hashedNewPassword,
})
.where(eq(users.id, userId));
return { success: true };
}),
// Export user data (backup) // Export user data (backup)
exportData: protectedProcedure.query(async ({ ctx }) => { exportData: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.session.user.id; const userId = ctx.session.user.id;
@@ -193,7 +256,7 @@ export const settingsRouter = createTRPCRouter({
name: user?.name ?? "", name: user?.name ?? "",
email: user?.email ?? "", email: user?.email ?? "",
}, },
clients: userClients.map(client => ({ clients: userClients.map((client) => ({
name: client.name, name: client.name,
email: client.email ?? undefined, email: client.email ?? undefined,
phone: client.phone ?? undefined, phone: client.phone ?? undefined,
@@ -204,7 +267,7 @@ export const settingsRouter = createTRPCRouter({
postalCode: client.postalCode ?? undefined, postalCode: client.postalCode ?? undefined,
country: client.country ?? undefined, country: client.country ?? undefined,
})), })),
businesses: userBusinesses.map(business => ({ businesses: userBusinesses.map((business) => ({
name: business.name, name: business.name,
email: business.email ?? undefined, email: business.email ?? undefined,
phone: business.phone ?? undefined, phone: business.phone ?? undefined,
@@ -219,7 +282,7 @@ export const settingsRouter = createTRPCRouter({
logoUrl: business.logoUrl ?? undefined, logoUrl: business.logoUrl ?? undefined,
isDefault: business.isDefault ?? false, isDefault: business.isDefault ?? false,
})), })),
invoices: userInvoices.map(invoice => ({ invoices: userInvoices.map((invoice) => ({
invoiceNumber: invoice.invoiceNumber, invoiceNumber: invoice.invoiceNumber,
businessName: invoice.business?.name, businessName: invoice.business?.name,
clientName: invoice.client.name, clientName: invoice.client.name,
@@ -307,10 +370,10 @@ export const settingsRouter = createTRPCRouter({
if (newInvoice && invoiceData.items.length > 0) { if (newInvoice && invoiceData.items.length > 0) {
// Import invoice items // Import invoice items
await tx.insert(invoiceItems).values( await tx.insert(invoiceItems).values(
invoiceData.items.map(item => ({ invoiceData.items.map((item) => ({
...item, ...item,
invoiceId: newInvoice.id, invoiceId: newInvoice.id,
})) })),
); );
} }
} }
@@ -321,8 +384,11 @@ export const settingsRouter = createTRPCRouter({
clients: input.clients.length, clients: input.clients.length,
businesses: input.businesses.length, businesses: input.businesses.length,
invoices: input.invoices.length, invoices: input.invoices.length,
items: input.invoices.reduce((sum, inv) => sum + inv.items.length, 0), items: input.invoices.reduce(
} (sum, inv) => sum + inv.items.length,
0,
),
},
}; };
}); });
}), }),
@@ -336,17 +402,17 @@ export const settingsRouter = createTRPCRouter({
.select({ count: clients.id }) .select({ count: clients.id })
.from(clients) .from(clients)
.where(eq(clients.createdById, userId)) .where(eq(clients.createdById, userId))
.then(result => result.length), .then((result) => result.length),
ctx.db ctx.db
.select({ count: businesses.id }) .select({ count: businesses.id })
.from(businesses) .from(businesses)
.where(eq(businesses.createdById, userId)) .where(eq(businesses.createdById, userId))
.then(result => result.length), .then((result) => result.length),
ctx.db ctx.db
.select({ count: invoices.id }) .select({ count: invoices.id })
.from(invoices) .from(invoices)
.where(eq(invoices.createdById, userId)) .where(eq(invoices.createdById, userId))
.then(result => result.length), .then((result) => result.length),
]); ]);
return { return {
@@ -358,11 +424,13 @@ export const settingsRouter = createTRPCRouter({
// Delete all user data (for account deletion) // Delete all user data (for account deletion)
deleteAllData: protectedProcedure deleteAllData: protectedProcedure
.input(z.object({ .input(
confirmText: z.string().refine(val => val === "DELETE ALL DATA", { z.object({
message: "You must type 'DELETE ALL DATA' to confirm", confirmText: z.string().refine((val) => val === "DELETE ALL DATA", {
message: "You must type 'DELETE ALL DATA' to confirm",
}),
}), }),
})) )
.mutation(async ({ ctx }) => { .mutation(async ({ ctx }) => {
const userId = ctx.session.user.id; const userId = ctx.session.user.id;
@@ -376,7 +444,9 @@ export const settingsRouter = createTRPCRouter({
if (userInvoiceIds.length > 0) { if (userInvoiceIds.length > 0) {
for (const invoice of userInvoiceIds) { for (const invoice of userInvoiceIds) {
await tx.delete(invoiceItems).where(eq(invoiceItems.invoiceId, invoice.id)); await tx
.delete(invoiceItems)
.where(eq(invoiceItems.invoiceId, invoice.id));
} }
} }