From bbf7dfad2e8108a32df2b709671939db2767e233 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Fri, 13 Mar 2026 05:09:55 +0800 Subject: [PATCH] feat(client): add client's page + CRUD operations modals --- src/App.tsx | 2 + src/api/clients.ts | 63 +++++++++ src/components/CreateClientModal.tsx | 75 +++++++++++ src/components/DeleteClientModal.tsx | 61 +++++++++ src/components/EditClientModal.tsx | 81 +++++++++++ src/components/FilterBar.tsx | 9 +- src/components/Modal.tsx | 16 ++- src/components/ui/TextAreaInput.tsx | 18 +++ src/locales/en.ts | 33 ++++- src/locales/fa.ts | 33 ++++- src/pages/Clients.tsx | 194 +++++++++++++++++++++++++++ src/pages/Workspaces.tsx | 1 + src/types/client.ts | 16 +++ 13 files changed, 588 insertions(+), 14 deletions(-) create mode 100644 src/api/clients.ts create mode 100644 src/components/CreateClientModal.tsx create mode 100644 src/components/DeleteClientModal.tsx create mode 100644 src/components/EditClientModal.tsx create mode 100644 src/components/ui/TextAreaInput.tsx create mode 100644 src/pages/Clients.tsx create mode 100644 src/types/client.ts diff --git a/src/App.tsx b/src/App.tsx index 7fdb635..948d9fe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import Workspaces from "./pages/Workspaces" import CreateWorkspace from "./pages/WorkspaceCreate" import WorkspaceDetail from "./pages/WorkspaceDetail" import EditWorkspace from "./pages/WorkspaceEdit" +import Clients from "./pages/Clients" const MainLayout = () => { return ( @@ -45,6 +46,7 @@ function App() { } /> } /> } /> + } /> diff --git a/src/api/clients.ts b/src/api/clients.ts new file mode 100644 index 0000000..fff6d68 --- /dev/null +++ b/src/api/clients.ts @@ -0,0 +1,63 @@ +import { authFetch } from "./client"; +import { type PaginatedClientList } from "../types/client"; + + +export const getClients = async (workspaceId: string, search: string = "", ordering: string = "") => { + const queryParams = new URLSearchParams({ workspace: workspaceId }); + + if (search) queryParams.append("search", search); + if (ordering) queryParams.append("ordering", ordering); + + const response = await authFetch(`/api/clients/?${queryParams.toString()}`); + if (!response.ok) { + throw new Error("Failed to fetch clients"); + } + return response.json(); +}; + +export const createClient = async (workspaceId: string, data: { name: string; notes: string }) => { + const response = await authFetch("/api/clients/", { + method: "POST", + body: JSON.stringify({ + workspace_id: workspaceId, + ...data, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error(errorData?.detail || errorData?.message || "Failed to create client"); + } + return response.json(); +}; + +export const updateClient = async (id: string, data: { name?: string; notes?: string }) => { + const response = await authFetch(`/api/clients/${id}/`, { + method: "PATCH", + body: JSON.stringify(data), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error(errorData?.detail || errorData?.message || "Failed to update client"); + } + return response.json(); +}; + +export const deleteClient = async (id: string) => { + const response = await authFetch(`/api/clients/${id}/`, { + method: "DELETE", + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error(errorData?.detail || errorData?.message || "Failed to delete client"); + } + + // DELETE requests often return 204 No Content, which throws an error on .json() + if (response.status === 204) { + return { success: true }; + } + + return response.json().catch(() => ({ success: true })); +}; diff --git a/src/components/CreateClientModal.tsx b/src/components/CreateClientModal.tsx new file mode 100644 index 0000000..6b44ad1 --- /dev/null +++ b/src/components/CreateClientModal.tsx @@ -0,0 +1,75 @@ +import { useState } from "react"; +import { createClient } from "../api/clients"; +import { useTranslation } from "../hooks/useTranslation"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { Modal } from "./Modal"; +import { TextAreaInput } from "./ui/TextAreaInput"; + +interface CreateClientModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; + workspaceId: string; +} + +export default function CreateClientModal({ isOpen, onClose, onSuccess, workspaceId }: CreateClientModalProps) { + const { t } = useTranslation(); + const [name, setName] = useState(""); + const [notes, setNotes] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async () => { + if (!name.trim()) return; + setIsLoading(true); + try { + await createClient(workspaceId, { name, notes }); + onSuccess(); + setName(""); + setNotes(""); + onClose(); + } catch (error) { + console.error(t.clients.errors.createFailed, error); + } finally { + setIsLoading(false); + } + }; + + const footer = ( + <> + + + + ); + + return ( + +
+
+ + setName(e.target.value)} + placeholder={t.clients.clientNamePlaceholder} + /> +
+
+ + setNotes(e.target.value)} + placeholder={t.clients.notesPlaceholder} + /> +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/DeleteClientModal.tsx b/src/components/DeleteClientModal.tsx new file mode 100644 index 0000000..d32f3bd --- /dev/null +++ b/src/components/DeleteClientModal.tsx @@ -0,0 +1,61 @@ +import { useState } from "react"; +import { type Client } from "../types/client"; +import { deleteClient } from "../api/clients"; +import { useTranslation } from "../hooks/useTranslation"; +import { Button } from "./ui/button"; +import { Modal } from "./Modal"; + +interface DeleteClientModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; + client: Client | null; +} + +export default function DeleteClientModal({ isOpen, onClose, onSuccess, client }: DeleteClientModalProps) { + const { t } = useTranslation(); + const [isLoading, setIsLoading] = useState(false); + + const handleDelete = async () => { + if (!client) return; + setIsLoading(true); + try { + await deleteClient(client.id); + onSuccess(); + onClose(); + } catch (error) { + console.error(t.clients.errors.deleteFailed, error); + } finally { + setIsLoading(false); + } + }; + + const footer = ( + <> + + + + ); + + return ( + +

+ {client ? t.clients.deleteConfirmMessage(client.name) : ""} +

+
+ ); +} diff --git a/src/components/EditClientModal.tsx b/src/components/EditClientModal.tsx new file mode 100644 index 0000000..cb4ec4b --- /dev/null +++ b/src/components/EditClientModal.tsx @@ -0,0 +1,81 @@ +import { useState, useEffect } from "react"; +import { type Client } from "../types/client"; +import { updateClient } from "../api/clients"; +import { useTranslation } from "../hooks/useTranslation"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { Modal } from "./Modal"; +import { TextAreaInput } from "./ui/TextAreaInput"; + +interface EditClientModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; + client: Client | null; +} + +export default function EditClientModal({ isOpen, onClose, onSuccess, client }: EditClientModalProps) { + const { t } = useTranslation(); + const [name, setName] = useState(""); + const [notes, setNotes] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (client) { + setName(client.name); + setNotes(client.notes || ""); + } + }, [client]); + + const handleSubmit = async () => { + if (!client || !name.trim()) return; + setIsLoading(true); + try { + await updateClient(client.id, { name, notes }); + onSuccess(); + onClose(); + } catch (error) { + console.error(t.clients.errors.updateFailed, error); + } finally { + setIsLoading(false); + } + }; + + const footer = ( + <> + + + + ); + + return ( + +
+
+ + setName(e.target.value)} + placeholder={t.clients.clientNamePlaceholder} + /> +
+
+ + setNotes(e.target.value)} + placeholder={t.clients.notesPlaceholder} + /> +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/FilterBar.tsx b/src/components/FilterBar.tsx index bbe972b..4fc49d7 100644 --- a/src/components/FilterBar.tsx +++ b/src/components/FilterBar.tsx @@ -1,6 +1,4 @@ -// src/components/FilterBar.tsx import { Search, ArrowUpDown } from 'lucide-react'; -import { useTranslation } from '../hooks/useTranslation'; interface FilterBarProps { searchQuery: string; @@ -8,11 +6,10 @@ interface FilterBarProps { ordering: string; setOrdering: (val: string) => void; orderingOptions: { value: string; label: string }[]; + searchPlaceholder: string; } -export default function FilterBar({ searchQuery, setSearchQuery, ordering, setOrdering, orderingOptions }: FilterBarProps) { - const { t } = useTranslation(); - +export default function FilterBar({ searchQuery, setSearchQuery, ordering, setOrdering, orderingOptions, searchPlaceholder }: FilterBarProps) { return (
@@ -21,7 +18,7 @@ export default function FilterBar({ searchQuery, setSearchQuery, ordering, setOr type="text" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} - placeholder={t.workspace?.searchPlaceholder || 'Search...'} + placeholder={searchPlaceholder || "Search..."} className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 text-slate-900 dark:text-white outline-none focus:ring-2 focus:ring-blue-500 transition-shadow" />
diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 6dc5a0f..5ee6936 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -1,5 +1,7 @@ import React, { useEffect } from "react"; import { X } from "lucide-react"; +import { Card } from "./ui/card"; +import { Button } from "./ui/button"; interface ModalProps { isOpen: boolean; @@ -37,21 +39,23 @@ export const Modal: React.FC = ({ className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4" onClick={onClose} > -
e.stopPropagation()} >

{title}

- +
{children}
@@ -61,7 +65,7 @@ export const Modal: React.FC = ({ {footer}
)} -
+ ); }; diff --git a/src/components/ui/TextAreaInput.tsx b/src/components/ui/TextAreaInput.tsx new file mode 100644 index 0000000..bae52b5 --- /dev/null +++ b/src/components/ui/TextAreaInput.tsx @@ -0,0 +1,18 @@ +import React from "react" + +export interface TextareaProps extends React.TextareaHTMLAttributes {} + +const TextAreaInput = React.forwardRef( + ({ className, ...props }, ref) => { + return ( +