Start working on invitations
This commit is contained in:
parent
5366524602
commit
7a17c95fec
12 changed files with 741 additions and 179 deletions
19
api/package-lock.json
generated
19
api/package-lock.json
generated
|
|
@ -11,10 +11,12 @@
|
|||
"dotenv": "^16.5.0",
|
||||
"hono": "^4.7.7",
|
||||
"hono-sessions": "^0.7.2",
|
||||
"nodemailer": "^7.0.4",
|
||||
"stream-chat": "^9.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.17",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
|
|
@ -555,6 +557,15 @@
|
|||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "6.4.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.17.tgz",
|
||||
"integrity": "sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/phoenix": {
|
||||
"version": "1.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
|
||||
|
|
@ -1093,6 +1104,14 @@
|
|||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "7.0.4",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.4.tgz",
|
||||
"integrity": "sha512-9O00Vh89/Ld2EcVCqJ/etd7u20UhME0f/NToPfArwPEe1Don1zy4mAIz6ariRr7mJ2RDxtaDzN0WJVdVXPtZaw==",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -12,10 +12,12 @@
|
|||
"dotenv": "^16.5.0",
|
||||
"hono": "^4.7.7",
|
||||
"hono-sessions": "^0.7.2",
|
||||
"nodemailer": "^7.0.4",
|
||||
"stream-chat": "^9.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.17",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
|
|
|
|||
309
api/src/database.types.ts
Normal file
309
api/src/database.types.ts
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
export type Json =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| { [key: string]: Json | undefined }
|
||||
| Json[]
|
||||
|
||||
export type Database = {
|
||||
public: {
|
||||
Tables: {
|
||||
devis: {
|
||||
Row: {
|
||||
client_email: string
|
||||
created_at: string
|
||||
date: string
|
||||
due_date: string
|
||||
id: string
|
||||
items: Json
|
||||
notes: string | null
|
||||
number: string
|
||||
status: Database["public"]["Enums"]["devis_status"]
|
||||
subtotal: number
|
||||
tax: number
|
||||
terms: string | null
|
||||
total: number
|
||||
updated_at: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
client_email: string
|
||||
created_at?: string
|
||||
date: string
|
||||
due_date: string
|
||||
id?: string
|
||||
items?: Json
|
||||
notes?: string | null
|
||||
number: string
|
||||
status?: Database["public"]["Enums"]["devis_status"]
|
||||
subtotal: number
|
||||
tax: number
|
||||
terms?: string | null
|
||||
total: number
|
||||
updated_at?: string
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
client_email?: string
|
||||
created_at?: string
|
||||
date?: string
|
||||
due_date?: string
|
||||
id?: string
|
||||
items?: Json
|
||||
notes?: string | null
|
||||
number?: string
|
||||
status?: Database["public"]["Enums"]["devis_status"]
|
||||
subtotal?: number
|
||||
tax?: number
|
||||
terms?: string | null
|
||||
total?: number
|
||||
updated_at?: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
feedbacks: {
|
||||
Row: {
|
||||
created_at: string | null
|
||||
fd_type: string
|
||||
id: number
|
||||
message: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string | null
|
||||
fd_type: string
|
||||
id?: number
|
||||
message: string
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string | null
|
||||
fd_type?: string
|
||||
id?: number
|
||||
message?: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
profiles: {
|
||||
Row: {
|
||||
avatar_url: string | null
|
||||
email: string | null
|
||||
id: string
|
||||
name: string | null
|
||||
}
|
||||
Insert: {
|
||||
avatar_url?: string | null
|
||||
email?: string | null
|
||||
id: string
|
||||
name?: string | null
|
||||
}
|
||||
Update: {
|
||||
avatar_url?: string | null
|
||||
email?: string | null
|
||||
id?: string
|
||||
name?: string | null
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
tablo_invitations: {
|
||||
Row: {
|
||||
created_at: string | null
|
||||
id: number
|
||||
invited_by: string
|
||||
invited_email: string
|
||||
status: string
|
||||
tablo_id: number
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string | null
|
||||
id?: number
|
||||
invited_by: string
|
||||
invited_email: string
|
||||
status?: string
|
||||
tablo_id: number
|
||||
}
|
||||
Update: {
|
||||
created_at?: string | null
|
||||
id?: number
|
||||
invited_by?: string
|
||||
invited_email?: string
|
||||
status?: string
|
||||
tablo_id?: number
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "fk_tablo_invitations_tablo_id"
|
||||
columns: ["tablo_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "tablos"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
tablos: {
|
||||
Row: {
|
||||
color: string | null
|
||||
created_at: string | null
|
||||
deleted_at: string | null
|
||||
id: number
|
||||
image: string | null
|
||||
name: string
|
||||
position: number
|
||||
status: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
color?: string | null
|
||||
created_at?: string | null
|
||||
deleted_at?: string | null
|
||||
id?: number
|
||||
image?: string | null
|
||||
name: string
|
||||
position?: number
|
||||
status?: string
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
color?: string | null
|
||||
created_at?: string | null
|
||||
deleted_at?: string | null
|
||||
id?: number
|
||||
image?: string | null
|
||||
name?: string
|
||||
position?: number
|
||||
status?: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
}
|
||||
Views: {
|
||||
[_ in never]: never
|
||||
}
|
||||
Functions: {
|
||||
[_ in never]: never
|
||||
}
|
||||
Enums: {
|
||||
devis_status: "draft" | "sent" | "accepted" | "rejected" | "expired"
|
||||
}
|
||||
CompositeTypes: {
|
||||
[_ in never]: never
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type DefaultSchema = Database[Extract<keyof Database, "public">]
|
||||
|
||||
export type Tables<
|
||||
DefaultSchemaTableNameOrOptions extends
|
||||
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
|
||||
| { schema: keyof Database },
|
||||
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||
schema: keyof Database
|
||||
}
|
||||
? keyof (Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
|
||||
Database[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
|
||||
: never = never,
|
||||
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
|
||||
? (Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
|
||||
Database[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
|
||||
Row: infer R
|
||||
}
|
||||
? R
|
||||
: never
|
||||
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
|
||||
DefaultSchema["Views"])
|
||||
? (DefaultSchema["Tables"] &
|
||||
DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
|
||||
Row: infer R
|
||||
}
|
||||
? R
|
||||
: never
|
||||
: never
|
||||
|
||||
export type TablesInsert<
|
||||
DefaultSchemaTableNameOrOptions extends
|
||||
| keyof DefaultSchema["Tables"]
|
||||
| { schema: keyof Database },
|
||||
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||
schema: keyof Database
|
||||
}
|
||||
? keyof Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
|
||||
: never = never,
|
||||
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
|
||||
? Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
||||
Insert: infer I
|
||||
}
|
||||
? I
|
||||
: never
|
||||
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
|
||||
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
|
||||
Insert: infer I
|
||||
}
|
||||
? I
|
||||
: never
|
||||
: never
|
||||
|
||||
export type TablesUpdate<
|
||||
DefaultSchemaTableNameOrOptions extends
|
||||
| keyof DefaultSchema["Tables"]
|
||||
| { schema: keyof Database },
|
||||
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||
schema: keyof Database
|
||||
}
|
||||
? keyof Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
|
||||
: never = never,
|
||||
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
|
||||
? Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
||||
Update: infer U
|
||||
}
|
||||
? U
|
||||
: never
|
||||
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
|
||||
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
|
||||
Update: infer U
|
||||
}
|
||||
? U
|
||||
: never
|
||||
: never
|
||||
|
||||
export type Enums<
|
||||
DefaultSchemaEnumNameOrOptions extends
|
||||
| keyof DefaultSchema["Enums"]
|
||||
| { schema: keyof Database },
|
||||
EnumName extends DefaultSchemaEnumNameOrOptions extends {
|
||||
schema: keyof Database
|
||||
}
|
||||
? keyof Database[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
|
||||
: never = never,
|
||||
> = DefaultSchemaEnumNameOrOptions extends { schema: keyof Database }
|
||||
? Database[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
|
||||
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
|
||||
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
|
||||
: never
|
||||
|
||||
export type CompositeTypes<
|
||||
PublicCompositeTypeNameOrOptions extends
|
||||
| keyof DefaultSchema["CompositeTypes"]
|
||||
| { schema: keyof Database },
|
||||
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
|
||||
schema: keyof Database
|
||||
}
|
||||
? keyof Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
|
||||
: never = never,
|
||||
> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database }
|
||||
? Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
|
||||
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
|
||||
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
|
||||
: never
|
||||
|
||||
export const Constants = {
|
||||
public: {
|
||||
Enums: {
|
||||
devis_status: ["draft", "sent", "accepted", "rejected", "expired"],
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { createClient, type User } from "@supabase/supabase-js";
|
||||
import type { Context, Next } from "hono";
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
// Create authentication middleware
|
||||
export const authMiddleware = async (c: Context, next: Next) => {
|
||||
|
|
@ -35,3 +36,17 @@ export const supabaseMiddleware = async (c: Context, next: Next) => {
|
|||
c.set("supabase", supabase);
|
||||
await next();
|
||||
};
|
||||
|
||||
export const emailMiddleware = async (c: Context, next: Next) => {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: "smtp.gmail.com",
|
||||
port: 465,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: process.env.EMAIL_USER,
|
||||
pass: process.env.EMAIL_KEY,
|
||||
},
|
||||
});
|
||||
c.set("transporter", transporter);
|
||||
await next();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
import { Hono } from "hono";
|
||||
import { authMiddleware } from "./middleware.js";
|
||||
import type { User } from "@supabase/supabase-js";
|
||||
import { authMiddleware, emailMiddleware } from "./middleware.js";
|
||||
import type { SupabaseClient, User } from "@supabase/supabase-js";
|
||||
import { StreamChat } from "stream-chat";
|
||||
import type { Transporter } from "nodemailer";
|
||||
|
||||
export const userRouter = new Hono<{
|
||||
Variables: {
|
||||
user: User;
|
||||
supabase: SupabaseClient;
|
||||
transporter: Transporter;
|
||||
};
|
||||
}>();
|
||||
|
||||
userRouter.use(authMiddleware);
|
||||
userRouter.use(emailMiddleware);
|
||||
|
||||
userRouter.get("/get-stream-token", async (c) => {
|
||||
const user = c.get("user");
|
||||
|
|
@ -29,3 +33,28 @@ userRouter.get("/get-stream-token", async (c) => {
|
|||
token,
|
||||
});
|
||||
});
|
||||
|
||||
userRouter.post("/invite", async (c) => {
|
||||
const sender = c.get("user");
|
||||
// const supabase = c.get("supabase");
|
||||
const transporter = c.get("transporter");
|
||||
const { email: recipientmail, tablo_id } = await c.req.json();
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
from: `${sender.email} via XTablo <noreply@xtablo.com>`,
|
||||
to: recipientmail,
|
||||
subject: "You have been invited to a tablo",
|
||||
html: `<p>You have been invited to a tablo with the following link: <a href="https://xtablo.com/tablo/${tablo_id}">https://xtablo.com/tablo/${tablo_id}</a></p>`,
|
||||
});
|
||||
|
||||
// const { data, error } = await supabase.auth.admin.inviteUserByEmail(
|
||||
// recipientmail,
|
||||
// {
|
||||
// data: {
|
||||
// tablo_id,
|
||||
// },
|
||||
// }
|
||||
// );
|
||||
|
||||
return c.json({ data: info });
|
||||
});
|
||||
|
|
|
|||
39
sql/09_create_tablo_invitations_table.sql
Normal file
39
sql/09_create_tablo_invitations_table.sql
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
-- Create tablo_invitations table
|
||||
CREATE TABLE IF NOT EXISTS tablo_invitations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
tablo_id INTEGER NOT NULL,
|
||||
invited_email VARCHAR(255) NOT NULL,
|
||||
invited_by UUID NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- Foreign key constraint to tablos table
|
||||
CONSTRAINT fk_tablo_invitations_tablo_id
|
||||
FOREIGN KEY (tablo_id) REFERENCES tablos(id) ON DELETE CASCADE,
|
||||
|
||||
-- Constraint to ensure status is one of the allowed values
|
||||
CONSTRAINT tablo_invitations_status_check
|
||||
CHECK (status IN ('pending', 'accepted', 'declined')),
|
||||
|
||||
-- Unique constraint to prevent duplicate invitations
|
||||
CONSTRAINT unique_tablo_invitation
|
||||
UNIQUE (tablo_id, invited_email)
|
||||
);
|
||||
|
||||
-- Enable Row Level Security
|
||||
ALTER TABLE tablo_invitations ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Create policy to allow tablo owners to insert invitations
|
||||
CREATE POLICY "Tablo owners can insert invitations" ON tablo_invitations
|
||||
FOR INSERT WITH CHECK (
|
||||
auth.uid() = invited_by AND
|
||||
EXISTS (
|
||||
SELECT 1 FROM tablos
|
||||
WHERE tablos.id = tablo_invitations.tablo_id
|
||||
AND tablos.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Create index for better query performance
|
||||
CREATE INDEX idx_tablo_invitations_tablo_id ON tablo_invitations(tablo_id);
|
||||
CREATE INDEX idx_tablo_invitations_invited_email ON tablo_invitations(invited_email);
|
||||
|
|
@ -31,7 +31,9 @@ export const App = () => {
|
|||
<SessionProvider>
|
||||
<UserStoreProvider>
|
||||
<Router>
|
||||
<div className={twMerge("min-h-screen bg-white", "dark:bg-white")}>
|
||||
<div
|
||||
className={twMerge("min-h-screen bg-white", "dark:bg-gray-900")}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/" element={<ProtectedRoute fallback="/login" />}>
|
||||
<Route
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ type StatusType = "todo" | "in_progress" | "done";
|
|||
interface CreateTabloModalProps {
|
||||
onClose: () => void;
|
||||
onCreate: (
|
||||
tabloData: Omit<Tablo, "id" | "user_id" | "created_at" | "deleted_at">
|
||||
tabloData: Omit<
|
||||
Tablo,
|
||||
"id" | "user_id" | "created_at" | "deleted_at" | "position"
|
||||
>
|
||||
) => void;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useState } from "react";
|
|||
import { ImageColorPicker } from "./ImageColorPicker";
|
||||
import { StatusPicker } from "./StatusPicker";
|
||||
import { Database } from "@ui/types/database.types";
|
||||
import { useInviteUser } from "@ui/hooks/invite";
|
||||
|
||||
type Tablo = Database["public"]["Tables"]["tablos"]["Row"];
|
||||
type StatusType = "todo" | "in_progress" | "done";
|
||||
|
|
@ -22,6 +23,9 @@ export const TabloModal = ({ tablo, onClose, onEdit }: TabloModalProps) => {
|
|||
tablo?.color || "bg-blue-500"
|
||||
);
|
||||
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
const inviteUser = useInviteUser();
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditData(null);
|
||||
};
|
||||
|
|
@ -38,6 +42,18 @@ export const TabloModal = ({ tablo, onClose, onEdit }: TabloModalProps) => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleSendInvite = () => {
|
||||
if (inviteEmail.trim()) {
|
||||
inviteUser({ email: inviteEmail, tablo_id: tablo?.id ?? 0 });
|
||||
setInviteEmail("");
|
||||
}
|
||||
};
|
||||
|
||||
const isEmailValid = (email: string): boolean => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
|
||||
if (!tablo) return null;
|
||||
|
||||
const currentData = editData || tablo;
|
||||
|
|
@ -96,6 +112,30 @@ export const TabloModal = ({ tablo, onClose, onEdit }: TabloModalProps) => {
|
|||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Invite User Section */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-3">
|
||||
Inviter un utilisateur
|
||||
</h3>
|
||||
<div className="flex space-x-2">
|
||||
<input
|
||||
type="email"
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
placeholder="Email de l'utilisateur à inviter"
|
||||
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendInvite}
|
||||
disabled={!isEmailValid(inviteEmail)}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed rounded-md transition-colors"
|
||||
>
|
||||
Inviter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
32
ui/src/hooks/invite.ts
Normal file
32
ui/src/hooks/invite.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { useMutation } from "@tanstack/react-query";
|
||||
import { api } from "@ui/lib/api";
|
||||
import { useSession } from "@ui/contexts/SessionContext";
|
||||
|
||||
// Invite user by email
|
||||
export const useInviteUser = () => {
|
||||
const { session } = useSession();
|
||||
const { mutate } = useMutation({
|
||||
mutationFn: async ({
|
||||
email,
|
||||
tablo_id,
|
||||
}: {
|
||||
email: string;
|
||||
tablo_id: number;
|
||||
}) => {
|
||||
const { data } = await api.post(
|
||||
"/api/v1/users/invite",
|
||||
{
|
||||
email,
|
||||
tablo_id,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${session?.access_token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
return mutate;
|
||||
};
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from "@ui/hooks/tablos";
|
||||
import { Database } from "@ui/types/database.types";
|
||||
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
|
||||
import { useSession } from "@ui/contexts/SessionContext";
|
||||
|
||||
type Tablo = Database["public"]["Tables"]["tablos"]["Row"];
|
||||
|
||||
|
|
@ -21,6 +22,7 @@ export const TabloPage = () => {
|
|||
const [deletingTablo, setDeletingTablo] = useState<Tablo | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const { session } = useSession();
|
||||
const { data: tablos, isLoading, error } = useTablosList();
|
||||
const createTabloMutation = useCreateTablo();
|
||||
const { mutateAsync: updateTablo } = useUpdateTablo();
|
||||
|
|
@ -41,7 +43,10 @@ export const TabloPage = () => {
|
|||
};
|
||||
|
||||
const createNewTablo = async (
|
||||
tabloData: Omit<Tablo, "id" | "user_id" | "created_at" | "deleted_at">
|
||||
tabloData: Omit<
|
||||
Tablo,
|
||||
"id" | "user_id" | "created_at" | "deleted_at" | "position"
|
||||
>
|
||||
) => {
|
||||
try {
|
||||
await createTabloMutation.mutateAsync(tabloData);
|
||||
|
|
@ -145,6 +150,18 @@ export const TabloPage = () => {
|
|||
setIsDeleting(false);
|
||||
};
|
||||
|
||||
const getUserRole = (tablo: Tablo) => {
|
||||
if (!session?.user) return "Invité";
|
||||
return tablo.user_id === session.user.id ? "Admin" : "Invité";
|
||||
};
|
||||
|
||||
const getRoleColor = (tablo: Tablo) => {
|
||||
if (!session?.user) return "text-gray-500 dark:text-gray-400";
|
||||
return tablo.user_id === session.user.id
|
||||
? "text-blue-600 dark:text-blue-400"
|
||||
: "text-gray-500 dark:text-gray-400";
|
||||
};
|
||||
|
||||
// Show loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
|
@ -303,17 +320,34 @@ export const TabloPage = () => {
|
|||
|
||||
{/* Content */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-gray-900 dark:text-white font-semibold text-lg truncate">
|
||||
{tablo.name}
|
||||
</h3>
|
||||
{/* Status badge */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-gray-900 dark:text-white font-semibold text-lg truncate">
|
||||
{tablo.name}
|
||||
</h3>
|
||||
{/* Status badge */}
|
||||
<div
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusBadgeColor(
|
||||
tablo.status
|
||||
)} flex-shrink-0`}
|
||||
>
|
||||
<span>{getStatusLabel(tablo.status)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusBadgeColor(
|
||||
tablo.status
|
||||
)} ml-2 flex-shrink-0`}
|
||||
className={`flex items-center gap-1 text-xs font-medium ${getRoleColor(
|
||||
tablo
|
||||
)}`}
|
||||
>
|
||||
<span>{getStatusLabel(tablo.status)}</span>
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M5 4a2 2 0 012-2h6a2 2 0 012 2v14l-5-2.5L5 18V4z" />
|
||||
</svg>
|
||||
<span>{getUserRole(tablo)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -458,7 +492,7 @@ export const TabloPage = () => {
|
|||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{tablos && tablos.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
||||
{/* Render tablos */}
|
||||
{tablos.map((tablo) => renderTablo(tablo))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,263 +4,301 @@ export type Json =
|
|||
| boolean
|
||||
| null
|
||||
| { [key: string]: Json | undefined }
|
||||
| Json[];
|
||||
| Json[]
|
||||
|
||||
export type Database = {
|
||||
public: {
|
||||
Tables: {
|
||||
devis: {
|
||||
Row: {
|
||||
client_email: string;
|
||||
created_at: string;
|
||||
date: string;
|
||||
due_date: string;
|
||||
id: string;
|
||||
items: Json;
|
||||
notes: string | null;
|
||||
number: string;
|
||||
status: Database["public"]["Enums"]["devis_status"];
|
||||
subtotal: number;
|
||||
tax: number;
|
||||
terms: string | null;
|
||||
total: number;
|
||||
updated_at: string;
|
||||
user_id: string;
|
||||
};
|
||||
client_email: string
|
||||
created_at: string
|
||||
date: string
|
||||
due_date: string
|
||||
id: string
|
||||
items: Json
|
||||
notes: string | null
|
||||
number: string
|
||||
status: Database["public"]["Enums"]["devis_status"]
|
||||
subtotal: number
|
||||
tax: number
|
||||
terms: string | null
|
||||
total: number
|
||||
updated_at: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
client_email: string;
|
||||
created_at?: string;
|
||||
date: string;
|
||||
due_date: string;
|
||||
id?: string;
|
||||
items?: Json;
|
||||
notes?: string | null;
|
||||
number: string;
|
||||
status?: Database["public"]["Enums"]["devis_status"];
|
||||
subtotal: number;
|
||||
tax: number;
|
||||
terms?: string | null;
|
||||
total: number;
|
||||
updated_at?: string;
|
||||
user_id: string;
|
||||
};
|
||||
client_email: string
|
||||
created_at?: string
|
||||
date: string
|
||||
due_date: string
|
||||
id?: string
|
||||
items?: Json
|
||||
notes?: string | null
|
||||
number: string
|
||||
status?: Database["public"]["Enums"]["devis_status"]
|
||||
subtotal: number
|
||||
tax: number
|
||||
terms?: string | null
|
||||
total: number
|
||||
updated_at?: string
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
client_email?: string;
|
||||
created_at?: string;
|
||||
date?: string;
|
||||
due_date?: string;
|
||||
id?: string;
|
||||
items?: Json;
|
||||
notes?: string | null;
|
||||
number?: string;
|
||||
status?: Database["public"]["Enums"]["devis_status"];
|
||||
subtotal?: number;
|
||||
tax?: number;
|
||||
terms?: string | null;
|
||||
total?: number;
|
||||
updated_at?: string;
|
||||
user_id?: string;
|
||||
};
|
||||
Relationships: [];
|
||||
};
|
||||
client_email?: string
|
||||
created_at?: string
|
||||
date?: string
|
||||
due_date?: string
|
||||
id?: string
|
||||
items?: Json
|
||||
notes?: string | null
|
||||
number?: string
|
||||
status?: Database["public"]["Enums"]["devis_status"]
|
||||
subtotal?: number
|
||||
tax?: number
|
||||
terms?: string | null
|
||||
total?: number
|
||||
updated_at?: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
feedbacks: {
|
||||
Row: {
|
||||
created_at: string | null;
|
||||
fd_type: string;
|
||||
id: number;
|
||||
message: string;
|
||||
user_id: string;
|
||||
};
|
||||
created_at: string | null
|
||||
fd_type: string
|
||||
id: number
|
||||
message: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string | null;
|
||||
fd_type: string;
|
||||
id?: number;
|
||||
message: string;
|
||||
user_id: string;
|
||||
};
|
||||
created_at?: string | null
|
||||
fd_type: string
|
||||
id?: number
|
||||
message: string
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string | null;
|
||||
fd_type?: string;
|
||||
id?: number;
|
||||
message?: string;
|
||||
user_id?: string;
|
||||
};
|
||||
Relationships: [];
|
||||
};
|
||||
created_at?: string | null
|
||||
fd_type?: string
|
||||
id?: number
|
||||
message?: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
profiles: {
|
||||
Row: {
|
||||
avatar_url: string | null;
|
||||
email: string | null;
|
||||
id: string;
|
||||
name: string | null;
|
||||
};
|
||||
avatar_url: string | null
|
||||
email: string | null
|
||||
id: string
|
||||
name: string | null
|
||||
}
|
||||
Insert: {
|
||||
avatar_url?: string | null;
|
||||
email?: string | null;
|
||||
id: string;
|
||||
name?: string | null;
|
||||
};
|
||||
avatar_url?: string | null
|
||||
email?: string | null
|
||||
id: string
|
||||
name?: string | null
|
||||
}
|
||||
Update: {
|
||||
avatar_url?: string | null;
|
||||
email?: string | null;
|
||||
id?: string;
|
||||
name?: string | null;
|
||||
};
|
||||
Relationships: [];
|
||||
};
|
||||
avatar_url?: string | null
|
||||
email?: string | null
|
||||
id?: string
|
||||
name?: string | null
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
tablo_invitations: {
|
||||
Row: {
|
||||
created_at: string | null
|
||||
id: number
|
||||
invited_by: string
|
||||
invited_email: string
|
||||
status: string
|
||||
tablo_id: number
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string | null
|
||||
id?: number
|
||||
invited_by: string
|
||||
invited_email: string
|
||||
status?: string
|
||||
tablo_id: number
|
||||
}
|
||||
Update: {
|
||||
created_at?: string | null
|
||||
id?: number
|
||||
invited_by?: string
|
||||
invited_email?: string
|
||||
status?: string
|
||||
tablo_id?: number
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "fk_tablo_invitations_tablo_id"
|
||||
columns: ["tablo_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "tablos"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
tablos: {
|
||||
Row: {
|
||||
color: string | null;
|
||||
created_at: string | null;
|
||||
deleted_at: string | null;
|
||||
id: number;
|
||||
image: string | null;
|
||||
name: string;
|
||||
status: string;
|
||||
user_id: string;
|
||||
};
|
||||
color: string | null
|
||||
created_at: string | null
|
||||
deleted_at: string | null
|
||||
id: number
|
||||
image: string | null
|
||||
name: string
|
||||
position: number
|
||||
status: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
color?: string | null;
|
||||
created_at?: string | null;
|
||||
deleted_at?: string | null;
|
||||
id?: number;
|
||||
image?: string | null;
|
||||
name: string;
|
||||
status?: string;
|
||||
user_id: string;
|
||||
};
|
||||
color?: string | null
|
||||
created_at?: string | null
|
||||
deleted_at?: string | null
|
||||
id?: number
|
||||
image?: string | null
|
||||
name: string
|
||||
position?: number
|
||||
status?: string
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
color?: string | null;
|
||||
created_at?: string | null;
|
||||
deleted_at?: string | null;
|
||||
id?: number;
|
||||
image?: string | null;
|
||||
name?: string;
|
||||
status?: string;
|
||||
user_id?: string;
|
||||
};
|
||||
Relationships: [];
|
||||
};
|
||||
};
|
||||
color?: string | null
|
||||
created_at?: string | null
|
||||
deleted_at?: string | null
|
||||
id?: number
|
||||
image?: string | null
|
||||
name?: string
|
||||
position?: number
|
||||
status?: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
}
|
||||
Views: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
[_ in never]: never
|
||||
}
|
||||
Functions: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
[_ in never]: never
|
||||
}
|
||||
Enums: {
|
||||
devis_status: "draft" | "sent" | "accepted" | "rejected" | "expired";
|
||||
};
|
||||
devis_status: "draft" | "sent" | "accepted" | "rejected" | "expired"
|
||||
}
|
||||
CompositeTypes: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
[_ in never]: never
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type DefaultSchema = Database[Extract<keyof Database, "public">];
|
||||
type DefaultSchema = Database[Extract<keyof Database, "public">]
|
||||
|
||||
export type Tables<
|
||||
DefaultSchemaTableNameOrOptions extends
|
||||
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
|
||||
| { schema: keyof Database },
|
||||
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||
schema: keyof Database;
|
||||
schema: keyof Database
|
||||
}
|
||||
? keyof (Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
|
||||
Database[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
|
||||
: never = never
|
||||
: never = never,
|
||||
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
|
||||
? (Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
|
||||
Database[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
|
||||
Row: infer R;
|
||||
Row: infer R
|
||||
}
|
||||
? R
|
||||
: never
|
||||
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
|
||||
DefaultSchema["Views"])
|
||||
? (DefaultSchema["Tables"] &
|
||||
DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
|
||||
Row: infer R;
|
||||
}
|
||||
? R
|
||||
DefaultSchema["Views"])
|
||||
? (DefaultSchema["Tables"] &
|
||||
DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
|
||||
Row: infer R
|
||||
}
|
||||
? R
|
||||
: never
|
||||
: never
|
||||
: never;
|
||||
|
||||
export type TablesInsert<
|
||||
DefaultSchemaTableNameOrOptions extends
|
||||
| keyof DefaultSchema["Tables"]
|
||||
| { schema: keyof Database },
|
||||
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||
schema: keyof Database;
|
||||
schema: keyof Database
|
||||
}
|
||||
? keyof Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
|
||||
: never = never
|
||||
: never = never,
|
||||
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
|
||||
? Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
||||
Insert: infer I;
|
||||
Insert: infer I
|
||||
}
|
||||
? I
|
||||
: never
|
||||
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
|
||||
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
|
||||
Insert: infer I;
|
||||
}
|
||||
? I
|
||||
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
|
||||
Insert: infer I
|
||||
}
|
||||
? I
|
||||
: never
|
||||
: never
|
||||
: never;
|
||||
|
||||
export type TablesUpdate<
|
||||
DefaultSchemaTableNameOrOptions extends
|
||||
| keyof DefaultSchema["Tables"]
|
||||
| { schema: keyof Database },
|
||||
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||
schema: keyof Database;
|
||||
schema: keyof Database
|
||||
}
|
||||
? keyof Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
|
||||
: never = never
|
||||
: never = never,
|
||||
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
|
||||
? Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
||||
Update: infer U;
|
||||
Update: infer U
|
||||
}
|
||||
? U
|
||||
: never
|
||||
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
|
||||
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
|
||||
Update: infer U;
|
||||
}
|
||||
? U
|
||||
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
|
||||
Update: infer U
|
||||
}
|
||||
? U
|
||||
: never
|
||||
: never
|
||||
: never;
|
||||
|
||||
export type Enums<
|
||||
DefaultSchemaEnumNameOrOptions extends
|
||||
| keyof DefaultSchema["Enums"]
|
||||
| { schema: keyof Database },
|
||||
EnumName extends DefaultSchemaEnumNameOrOptions extends {
|
||||
schema: keyof Database;
|
||||
schema: keyof Database
|
||||
}
|
||||
? keyof Database[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
|
||||
: never = never
|
||||
: never = never,
|
||||
> = DefaultSchemaEnumNameOrOptions extends { schema: keyof Database }
|
||||
? Database[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
|
||||
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
|
||||
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
|
||||
: never;
|
||||
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
|
||||
: never
|
||||
|
||||
export type CompositeTypes<
|
||||
PublicCompositeTypeNameOrOptions extends
|
||||
| keyof DefaultSchema["CompositeTypes"]
|
||||
| { schema: keyof Database },
|
||||
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
|
||||
schema: keyof Database;
|
||||
schema: keyof Database
|
||||
}
|
||||
? keyof Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
|
||||
: never = never
|
||||
: never = never,
|
||||
> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database }
|
||||
? Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
|
||||
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
|
||||
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
|
||||
: never;
|
||||
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
|
||||
: never
|
||||
|
||||
export const Constants = {
|
||||
public: {
|
||||
|
|
@ -268,4 +306,4 @@ export const Constants = {
|
|||
devis_status: ["draft", "sent", "accepted", "rejected", "expired"],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
} as const
|
||||
|
|
|
|||
Loading…
Reference in a new issue