Bernt Christian Egeland 7 ay önce
ebeveyn
işleme
7a5a84f6e8

+ 5 - 0
prisma/migrations/20260216171151_custom_roles/migration.sql

@@ -0,0 +1,5 @@
+-- AlterTable
+ALTER TABLE "team_invitations" ADD COLUMN     "roleId" TEXT;
+
+-- AddForeignKey
+ALTER TABLE "team_invitations" ADD CONSTRAINT "team_invitations_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "roles"("id") ON DELETE SET NULL ON UPDATE CASCADE;

+ 6 - 2
prisma/schema.prisma

@@ -505,8 +505,9 @@ model Role {
   organizationId String
   organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
 
-  permissions Permission[]
-  members     OrganizationMember[]
+  permissions     Permission[]
+  members         OrganizationMember[]
+  teamInvitations TeamInvitation[]
 
   @@unique([organizationId, name])
   @@map("roles")
@@ -545,6 +546,9 @@ model TeamInvitation {
   organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
   invitedById    String
 
+  roleId         String?
+  customRole     Role?        @relation(fields: [roleId], references: [id], onDelete: SetNull)
+
   @@unique([email, organizationId])
   @@index([token])
   @@map("team_invitations")

+ 38 - 31
src/app/(authenticated)/dashboard-client.tsx

@@ -46,6 +46,7 @@ interface ServiceItem {
 }
 
 interface DashboardStats {
+  isAdmin: boolean;
   activeJobs: number;
   pendingJobs: number;
   todayRevenue: number;
@@ -103,7 +104,7 @@ export function DashboardClient({
       </div>
 
       {/* Quick stats row */}
-      <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
+      <div className={`grid grid-cols-2 gap-3 ${stats.isAdmin ? "sm:grid-cols-4" : "sm:grid-cols-2"}`}>
         <Card className="border-0 shadow-sm">
           <CardContent className="p-4">
             <div className="flex items-center gap-2 text-muted-foreground">
@@ -122,26 +123,30 @@ export function DashboardClient({
             <p className="mt-1 text-2xl font-bold">{stats.pendingJobs}</p>
           </CardContent>
         </Card>
-        <Card className="border-0 shadow-sm">
-          <CardContent className="p-4">
-            <div className="flex items-center gap-2 text-muted-foreground">
-              <DollarSign className="h-4 w-4" />
-              <span className="text-xs font-medium">Today&apos;s Revenue</span>
-            </div>
-            <p className="mt-1 text-2xl font-bold">
-              {formatCurrency(stats.todayRevenue, currencyCode)}
-            </p>
-          </CardContent>
-        </Card>
-        <Card className="border-0 shadow-sm">
-          <CardContent className="p-4">
-            <div className="flex items-center gap-2 text-muted-foreground">
-              <Users className="h-4 w-4" />
-              <span className="text-xs font-medium">Total Customers</span>
-            </div>
-            <p className="mt-1 text-2xl font-bold">{stats.totalCustomers}</p>
-          </CardContent>
-        </Card>
+        {stats.isAdmin && (
+          <Card className="border-0 shadow-sm">
+            <CardContent className="p-4">
+              <div className="flex items-center gap-2 text-muted-foreground">
+                <DollarSign className="h-4 w-4" />
+                <span className="text-xs font-medium">Today&apos;s Revenue</span>
+              </div>
+              <p className="mt-1 text-2xl font-bold">
+                {formatCurrency(stats.todayRevenue, currencyCode)}
+              </p>
+            </CardContent>
+          </Card>
+        )}
+        {stats.isAdmin && (
+          <Card className="border-0 shadow-sm">
+            <CardContent className="p-4">
+              <div className="flex items-center gap-2 text-muted-foreground">
+                <Users className="h-4 w-4" />
+                <span className="text-xs font-medium">Total Customers</span>
+              </div>
+              <p className="mt-1 text-2xl font-bold">{stats.totalCustomers}</p>
+            </CardContent>
+          </Card>
+        )}
       </div>
 
       {/* Upcoming Reminders */}
@@ -226,13 +231,13 @@ export function DashboardClient({
                 <TableHead>Vehicle</TableHead>
                 <TableHead className="hidden sm:table-cell">Customer</TableHead>
                 <TableHead>Title</TableHead>
-                <TableHead className="w-22.5 text-right">Total</TableHead>
+                {stats.isAdmin && <TableHead className="w-22.5 text-right">Total</TableHead>}
               </TableRow>
             </TableHeader>
             <TableBody>
               {stats.recentServices.length === 0 ? (
                 <TableRow>
-                  <TableCell colSpan={5} className="h-20 text-center text-muted-foreground">
+                  <TableCell colSpan={stats.isAdmin ? 5 : 4} className="h-20 text-center text-muted-foreground">
                     No completed services yet
                   </TableCell>
                 </TableRow>
@@ -265,14 +270,16 @@ export function DashboardClient({
                         {s.vehicle.customer?.name || "-"}
                       </TableCell>
                       <TableCell className="font-medium">{s.title}</TableCell>
-                      <TableCell className="text-right font-semibold">
-                        <span className="inline-flex items-center gap-2">
-                          {navigatingId === s.id && (
-                            <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
-                          )}
-                          {formatCurrency(displayTotal, currencyCode)}
-                        </span>
-                      </TableCell>
+                      {stats.isAdmin && (
+                        <TableCell className="text-right font-semibold">
+                          <span className="inline-flex items-center gap-2">
+                            {navigatingId === s.id && (
+                              <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
+                            )}
+                            {formatCurrency(displayTotal, currencyCode)}
+                          </span>
+                        </TableCell>
+                      )}
                     </TableRow>
                   );
                 })

+ 80 - 36
src/app/(authenticated)/settings/team/team-settings.tsx

@@ -13,6 +13,7 @@ import {
   Select,
   SelectContent,
   SelectItem,
+  SelectSeparator,
   SelectTrigger,
   SelectValue,
 } from "@/components/ui/select";
@@ -31,12 +32,13 @@ import { updateRole } from "@/features/team/Actions/updateRole";
 import { deleteRole } from "@/features/team/Actions/deleteRole";
 import { assignRole } from "@/features/team/Actions/assignRole";
 import { permissionGroups } from "@/lib/permissions";
-import { Crown, Loader2, Mail, Pencil, Plus, Shield, ShieldCheck, Trash2, User, Users, X } from "lucide-react";
+import { Copy, Crown, Loader2, Mail, Pencil, Plus, Shield, ShieldCheck, Trash2, User, Users, X } from "lucide-react";
 
 interface Member {
   id: string;
   role: string;
   roleId: string | null;
+  customRoleName: string | null;
   user: { id: string; name: string; email: string };
 }
 
@@ -50,6 +52,9 @@ interface PendingInvitation {
   id: string;
   email: string;
   role: string;
+  roleId: string | null;
+  token: string;
+  customRole: { name: string } | null;
   createdAt: Date;
   expiresAt: Date;
 }
@@ -92,6 +97,7 @@ export function TeamSettings({
   const [orgName, setOrgName] = useState("");
   const [inviteEmail, setInviteEmail] = useState("");
   const [inviteRole, setInviteRole] = useState<string>("member");
+  const [inviteRoleId, setInviteRoleId] = useState<string | null>(null);
 
   // Role form state
   const [showRoleForm, setShowRoleForm] = useState(false);
@@ -189,16 +195,28 @@ export function TeamSettings({
     }
   };
 
-  const handleAssignRole = async (memberId: string, roleId: string) => {
-    const result = await assignRole({
-      memberId,
-      roleId: roleId === "none" ? null : roleId,
-    });
+  const handleAssignRole = async (memberId: string, value: string) => {
+    let role: "admin" | "member";
+    let roleId: string | null;
+
+    if (value === "admin") {
+      role = "admin";
+      roleId = null;
+    } else if (value === "member") {
+      role = "member";
+      roleId = null;
+    } else {
+      // Custom role ID
+      role = "member";
+      roleId = value;
+    }
+
+    const result = await assignRole({ memberId, role, roleId });
     if (result.success) {
-      toast.success("Role assigned");
+      toast.success("Role updated");
       router.refresh();
     } else {
-      modal.open("error", "Error", result.error || "Failed to assign role");
+      modal.open("error", "Error", result.error || "Failed to update role");
     }
   };
 
@@ -219,7 +237,7 @@ export function TeamSettings({
     e.preventDefault();
     if (!inviteEmail.trim()) return;
     setLoading(true);
-    const result = await inviteMember({ email: inviteEmail, role: inviteRole });
+    const result = await inviteMember({ email: inviteEmail, role: inviteRole, roleId: inviteRoleId || undefined });
     if (result.success) {
       const data = result.data as { invited: boolean; userNotFound?: boolean };
       if (data.userNotFound) {
@@ -234,7 +252,7 @@ export function TeamSettings({
         });
         if (ok) {
           setLoading(true);
-          const sendResult = await sendInvitation({ email: emailToInvite, role: roleToInvite });
+          const sendResult = await sendInvitation({ email: emailToInvite, role: roleToInvite, roleId: inviteRoleId || undefined });
           if (sendResult.success) {
             setInviteEmail("");
             router.refresh();
@@ -375,42 +393,33 @@ export function TeamSettings({
                 <div className="flex items-center gap-2">
                   {isOwner && member.role !== "owner" ? (
                     <Select
-                      value={member.role}
-                      onValueChange={(v) => handleRoleChange(member.id, v)}
+                      value={member.roleId || member.role}
+                      onValueChange={(v) => handleAssignRole(member.id, v)}
                     >
-                      <SelectTrigger className="h-8 w-28 text-xs">
+                      <SelectTrigger className="h-8 w-36 text-xs">
                         <SelectValue />
                       </SelectTrigger>
                       <SelectContent>
                         <SelectItem value="admin">Admin</SelectItem>
                         <SelectItem value="member">Member</SelectItem>
+                        {roles.length > 0 && (
+                          <>
+                            <SelectSeparator />
+                            {roles.map((r) => (
+                              <SelectItem key={r.id} value={r.id}>
+                                {r.name}
+                              </SelectItem>
+                            ))}
+                          </>
+                        )}
                       </SelectContent>
                     </Select>
                   ) : (
                     <Badge variant="outline" className={`text-xs ${roleColors[member.role] || ""}`}>
                       {roleIcons[member.role]}
-                      <span className="ml-1 capitalize">{member.role}</span>
+                      <span className="ml-1 capitalize">{member.customRoleName || member.role}</span>
                     </Badge>
                   )}
-                  {/* Custom role assignment */}
-                  {isAdmin && member.role !== "owner" && roles.length > 0 && (
-                    <Select
-                      value={member.roleId || "none"}
-                      onValueChange={(v) => handleAssignRole(member.id, v)}
-                    >
-                      <SelectTrigger className="h-8 w-36 text-xs">
-                        <SelectValue placeholder="Custom role" />
-                      </SelectTrigger>
-                      <SelectContent>
-                        <SelectItem value="none">No custom role</SelectItem>
-                        {roles.map((r) => (
-                          <SelectItem key={r.id} value={r.id}>
-                            {r.name}
-                          </SelectItem>
-                        ))}
-                      </SelectContent>
-                    </Select>
-                  )}
                   {isAdmin && member.role !== "owner" && (
                     <Button
                       variant="ghost"
@@ -438,13 +447,35 @@ export function TeamSettings({
                   required
                 />
               </div>
-              <Select value={inviteRole} onValueChange={setInviteRole}>
-                <SelectTrigger className="w-28">
+              <Select
+                value={inviteRoleId ? `custom:${inviteRoleId}` : inviteRole}
+                onValueChange={(v) => {
+                  if (v.startsWith("custom:")) {
+                    const id = v.replace("custom:", "");
+                    setInviteRole("member");
+                    setInviteRoleId(id);
+                  } else {
+                    setInviteRole(v);
+                    setInviteRoleId(null);
+                  }
+                }}
+              >
+                <SelectTrigger className="w-36">
                   <SelectValue />
                 </SelectTrigger>
                 <SelectContent>
                   <SelectItem value="admin">Admin</SelectItem>
                   <SelectItem value="member">Member</SelectItem>
+                  {roles.length > 0 && (
+                    <>
+                      <SelectSeparator />
+                      {roles.map((r) => (
+                        <SelectItem key={r.id} value={`custom:${r.id}`}>
+                          {r.name}
+                        </SelectItem>
+                      ))}
+                    </>
+                  )}
                 </SelectContent>
               </Select>
               <Button type="submit" disabled={loading || !inviteEmail.trim()}>
@@ -480,13 +511,26 @@ export function TeamSettings({
                   <div className="min-w-0 flex-1">
                     <p className="truncate font-medium text-sm">{invitation.email}</p>
                     <p className="text-xs text-muted-foreground">
-                      Invited as {invitation.role} &middot; Expires {new Date(invitation.expiresAt).toLocaleDateString()}
+                      Invited as {invitation.customRole?.name || invitation.role} &middot; Expires {new Date(invitation.expiresAt).toLocaleDateString()}
                     </p>
                   </div>
                   <div className="flex items-center gap-2">
                     <Badge variant="outline" className="text-xs bg-yellow-500/10 text-yellow-600 border-yellow-500/20">
                       Pending
                     </Badge>
+                    <Button
+                      variant="ghost"
+                      size="icon"
+                      className="h-8 w-8 text-muted-foreground"
+                      title="Copy invite link"
+                      onClick={() => {
+                        const url = `${window.location.origin}/auth/sign-up?invite=${invitation.token}`;
+                        navigator.clipboard.writeText(url);
+                        toast.success("Invite link copied to clipboard");
+                      }}
+                    >
+                      <Copy className="h-3.5 w-3.5" />
+                    </Button>
                     <Button
                       variant="ghost"
                       size="icon"

+ 1 - 0
src/features/team/Actions/acceptInvitation.ts

@@ -54,6 +54,7 @@ export async function acceptInvitation(input: unknown) {
           userId: session.user.id,
           organizationId: invitation.organizationId,
           role: invitation.role,
+          roleId: invitation.roleId,
         },
       }),
       db.teamInvitation.update({

+ 4 - 1
src/features/team/Actions/assignRole.ts

@@ -29,7 +29,10 @@ export async function assignRole(input: unknown) {
 
     await db.organizationMember.update({
       where: { id: data.memberId },
-      data: { roleId: data.roleId },
+      data: {
+        ...(data.role && { role: data.role }),
+        roleId: data.roleId,
+      },
     });
 
     revalidatePath("/settings/team");

+ 5 - 0
src/features/team/Actions/getPendingInvitations.ts

@@ -17,8 +17,13 @@ export async function getPendingInvitations() {
         id: true,
         email: true,
         role: true,
+        roleId: true,
+        token: true,
         createdAt: true,
         expiresAt: true,
+        customRole: {
+          select: { name: true },
+        },
       },
     });
 

+ 23 - 4
src/features/team/Actions/sendInvitation.ts

@@ -4,7 +4,7 @@ import { randomUUID } from "crypto";
 import { db } from "@/lib/db";
 import { withAuth } from "@/lib/with-auth";
 import { sendInvitationSchema } from "../Schema/teamSchema";
-import { sendMail, getFromAddress } from "@/lib/email";
+import { sendOrgMail, getOrgFromAddress } from "@/lib/email";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
 import { revalidatePath } from "next/cache";
 
@@ -30,10 +30,26 @@ export async function sendInvitation(input: unknown) {
     });
     if (existing) throw new Error("An invitation has already been sent to this email");
 
+    // Remove any stale (cancelled/accepted/expired) invitations so the unique constraint doesn't block re-invites
+    await db.teamInvitation.deleteMany({
+      where: {
+        email: data.email,
+        organizationId,
+        status: { not: "pending" },
+      },
+    });
+
     // Create invitation with 7-day expiry
     const token = randomUUID();
     const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
 
+    // Look up custom role name if roleId is provided
+    let customRoleName: string | undefined;
+    if (data.roleId) {
+      const customRole = await db.role.findUnique({ where: { id: data.roleId }, select: { name: true } });
+      customRoleName = customRole?.name;
+    }
+
     await db.teamInvitation.create({
       data: {
         email: data.email,
@@ -42,22 +58,25 @@ export async function sendInvitation(input: unknown) {
         expiresAt,
         organizationId,
         invitedById: userId,
+        roleId: data.roleId,
       },
     });
 
-    const from = await getFromAddress();
+    const from = await getOrgFromAddress(organizationId);
 
     const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
     const signupUrl = `${baseUrl}/auth/sign-up?invite=${token}`;
 
-    await sendMail({
+    const roleLabel = customRoleName || data.role;
+
+    await sendOrgMail(organizationId, {
       from,
       to: data.email,
       subject: `You've been invited to join ${membership.organization.name} on Torqvoice`,
       html: `
         <div style="font-family: sans-serif; max-width: 480px; margin: 0 auto;">
           <h2>Team Invitation</h2>
-          <p>You've been invited to join <strong>${membership.organization.name}</strong> on Torqvoice as a <strong>${data.role}</strong>.</p>
+          <p>You've been invited to join <strong>${membership.organization.name}</strong> on Torqvoice as a <strong>${roleLabel}</strong>.</p>
           <p>Click the button below to create your account and join the team:</p>
           <div style="margin: 24px 0;">
             <a href="${signupUrl}" style="display: inline-block; padding: 12px 24px; background-color: #171717; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">

+ 5 - 0
src/features/team/Actions/teamActions.ts

@@ -18,6 +18,9 @@ export async function getOrganization() {
           include: {
             members: {
               orderBy: { role: "asc" },
+              include: {
+                customRole: { select: { id: true, name: true } },
+              },
             },
           },
         },
@@ -38,6 +41,7 @@ export async function getOrganization() {
       id: m.id,
       role: m.role,
       roleId: m.roleId,
+      customRoleName: m.customRole?.name || null,
       user: userMap.get(m.userId) || { id: m.userId, name: "Unknown", email: "" },
     }));
 
@@ -124,6 +128,7 @@ export async function inviteMember(input: unknown) {
         userId: invitedUser.id,
         organizationId,
         role: data.role,
+        roleId: data.roleId,
       },
     });
 

+ 3 - 0
src/features/team/Schema/teamSchema.ts

@@ -10,6 +10,7 @@ export const createOrganizationSchema = z.object({
 export const inviteMemberSchema = z.object({
   email: z.string().email(),
   role: z.enum(["admin", "member"]).default("member"),
+  roleId: z.string().optional(),
 });
 
 export const updateMemberRoleSchema = z.object({
@@ -44,12 +45,14 @@ export const updateRoleSchema = z.object({
 
 export const assignRoleSchema = z.object({
   memberId: z.string(),
+  role: z.enum(["admin", "member"]).optional(),
   roleId: z.string().nullable(),
 });
 
 export const sendInvitationSchema = z.object({
   email: z.string().email(),
   role: z.enum(["admin", "member"]).default("member"),
+  roleId: z.string().optional(),
 });
 
 export const cancelInvitationSchema = z.object({

+ 3 - 1
src/features/vehicles/Actions/dashboardActions.ts

@@ -5,7 +5,8 @@ import { withAuth } from "@/lib/with-auth";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
 
 export async function getDashboardStats() {
-  return withAuth(async ({ organizationId }) => {
+  return withAuth(async ({ organizationId, role }) => {
+    const isAdmin = role === "owner" || role === "admin" || role === "super_admin";
     const today = new Date();
     today.setHours(0, 0, 0, 0);
     const tomorrow = new Date(today);
@@ -77,6 +78,7 @@ export async function getDashboardStats() {
     ]);
 
     return {
+      isAdmin,
       activeJobs,
       pendingJobs,
       todayRevenue: todayCompletedRecords._sum.totalAmount || 0,