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 (
+
+ )
+ }
+)
+TextAreaInput.displayName = "TextAreaInput"
+
+export { TextAreaInput }
diff --git a/src/locales/en.ts b/src/locales/en.ts
index 5c5fe0d..9280cfa 100644
--- a/src/locales/en.ts
+++ b/src/locales/en.ts
@@ -151,6 +151,7 @@ export const en = {
noUsersFound: "No user found",
selectRole: "Select Role",
add: "Add",
+ searchPlaceholder: "Seach Workspaces...",
orderByUpdatedDesc: "Recently Updated",
orderByCreatedDesc: "Newest First",
orderByCreatedAsc: "Oldest First",
@@ -179,5 +180,35 @@ export const en = {
errorLoad: "Failed to load workspace data.",
cannotAddSelf: "You are automatically the owner.",
},
- }
+ },
+
+ clients: {
+ title: "Clients",
+ description: (workspaceName: string) => `Manage clients for ${workspaceName}`,
+ addClient: "Add Client",
+ searchPlaceholder: "Search clients...",
+ noClients: "No clients found",
+ noClientsSearch: "Try adjusting your search query.",
+ noClientsAdd: "Add your first client to get started.",
+ addedOn: "Added",
+ selectWorkspace: "Please select a workspace first.",
+ modalTitle: "Create New Client",
+ clientName: "Client Name",
+ clientNamePlaceholder: "e.g. Acme Corp",
+ notes: "Notes",
+ notesPlaceholder: "Optional details...",
+ create: "Create",
+ cancel: "Cancel",
+ editClient: "Edit Client",
+ deleteConfirmTitle: "Delete Client",
+ deleteConfirmMessage: (name: string) => `Are you sure you want to delete ${name}?`,
+ delete: "Delete",
+ saveChanges: "Save Changes",
+ errors: {
+ createFailed: "Failed to create client",
+ fetchFailed: "Failed to fetch clients",
+ updateFailed: "Failed to update client",
+ deleteFailed: "Failed to delete client"
+ }
+ },
}
diff --git a/src/locales/fa.ts b/src/locales/fa.ts
index b6fb77c..3ca7850 100644
--- a/src/locales/fa.ts
+++ b/src/locales/fa.ts
@@ -122,7 +122,7 @@ export const fa = {
selectedMembers: "اعضای انتخاب شده",
creating: "در حال ایجاد...",
submit: "ایجاد",
- cancel: "انصراف",
+ cancel: "لغو",
loading: "در حال بارگذاری...",
confirmDelete: "آیا از حذف این فضای کاری اطمینان دارید؟",
deleteError: "خطا در حذف فضای کاری",
@@ -152,6 +152,7 @@ export const fa = {
noUsersFound: "کاربری یافت نشد",
selectRole: "انتخاب نقش",
add: "افزودن",
+ searchPlaceholder: "جستوجوی فضاهای کاری...",
orderByUpdatedDesc: "آخرین ویرایش",
orderByCreatedDesc: "جدیدترین",
orderByCreatedAsc: "قدیمیترین",
@@ -181,4 +182,34 @@ export const fa = {
errorCreate: "ایجاد فضای کاری ناموفق بود.",
successCreate: "فضای کاری با موفقیت ایجاد شد.",
},
+
+ clients: {
+ title: "مشتریان",
+ description: (workspaceName: string) => `مدیریت مشتریان برای ${workspaceName}`,
+ addClient: "افزودن مشتری",
+ searchPlaceholder: "جستجوی مشتریان...",
+ noClients: "مشتری یافت نشد",
+ noClientsSearch: "لطفاً عبارت جستجو را تغییر دهید.",
+ noClientsAdd: "برای شروع اولین مشتری خود را اضافه کنید.",
+ addedOn: "تاریخ افزودن",
+ selectWorkspace: "لطفاً ابتدا یک فضای کاری انتخاب کنید.",
+ modalTitle: "ایجاد مشتری جدید",
+ clientName: "نام مشتری",
+ clientNamePlaceholder: "مثال: شرکت الف",
+ notes: "یادداشتها",
+ notesPlaceholder: "توضیحات اختیاری...",
+ create: "ایجاد",
+ cancel: "انصراف",
+ editClient: "ویرایش مشتری",
+ deleteConfirmTitle: "حذف مشتری",
+ deleteConfirmMessage: (name: string) => `آیا از حذف ${name} اطمینان دارید؟`,
+ delete: "حذف",
+ saveChanges: "ذخیره تغییرات",
+ errors: {
+ createFailed: "خطا در ایجاد مشتری",
+ fetchFailed: "خطا در دریافت لیست مشتریان",
+ updateFailed: "خطا در ویرایش مشتری",
+ deleteFailed: "خطا در حذف مشتری"
+ }
+ },
}
diff --git a/src/pages/Clients.tsx b/src/pages/Clients.tsx
new file mode 100644
index 0000000..bfd171b
--- /dev/null
+++ b/src/pages/Clients.tsx
@@ -0,0 +1,194 @@
+import { useEffect, useState } from "react"
+import { Plus, Building2, Loader2, Pencil, Trash2 } from "lucide-react"
+import { useWorkspace } from "../context/WorkspaceContext"
+import { useTranslation } from "../hooks/useTranslation"
+import { type Client } from "../types/client"
+import { getClients } from "../api/clients"
+import CreateClientModal from "../components/CreateClientModal"
+import EditClientModal from "../components/EditClientModal"
+import DeleteClientModal from "../components/DeleteClientModal"
+import FilterBar from "../components/FilterBar"
+import { Button } from "../components/ui/button"
+import { Card } from "../components/ui/card"
+
+export default function Clients() {
+ const { activeWorkspace } = useWorkspace()
+ const [clients, setClients] = useState([])
+ const [isLoading, setIsLoading] = useState(true)
+
+ // Filter States
+ const [searchQuery, setSearchQuery] = useState("")
+ const [debouncedSearch, setDebouncedSearch] = useState("")
+ const [ordering, setOrdering] = useState("-created_at")
+
+ // Modal States
+ const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
+ const [editClient, setEditClient] = useState(null)
+ const [deleteClient, setDeleteClient] = useState(null)
+
+ const { t, lang } = useTranslation()
+ const isFa = lang === "fa"
+
+ const orderingOptions = [
+ { value: "-created_at", label: isFa ? "جدیدترین" : "Newest First" },
+ { value: "created_at", label: isFa ? "قدیمیترین" : "Oldest First" },
+ { value: "name", label: isFa ? "نام (الف-ی)" : "Name (A-Z)" },
+ { value: "-name", label: isFa ? "نام (ی-الف)" : "Name (Z-A)" },
+ { value: "-updated_at", label: isFa ? "اخیراً بروزرسانی شده" : "Recently Updated" },
+ ]
+
+ // Debounce search input to avoid spamming the API
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedSearch(searchQuery)
+ }, 500)
+ return () => clearTimeout(handler)
+ }, [searchQuery])
+
+ const fetchClientsList = async () => {
+ if (!activeWorkspace?.id) {
+ setIsLoading(false)
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ const data: any = await getClients(activeWorkspace.id, debouncedSearch, ordering)
+ setClients(data?.results || (Array.isArray(data) ? data : []))
+ } catch (error) {
+ console.error(t.clients.errors.fetchFailed, error)
+ setClients([])
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const formatDate = (dateStr: string | undefined) => {
+ if (!dateStr) return "-"
+ try {
+ const date = new Date(dateStr)
+ return new Intl.DateTimeFormat(isFa ? "fa-IR" : "en-US", {
+ dateStyle: "long",
+ timeZone: "Asia/Tehran"
+ }).format(date)
+ } catch (e) {
+ return dateStr
+ }
+ }
+
+ // Refetch when workspace, debounced search, or ordering changes
+ useEffect(() => {
+ fetchClientsList()
+ }, [activeWorkspace?.id, debouncedSearch, ordering])
+
+ if (!activeWorkspace) {
+ return (
+
+ {t.clients.selectWorkspace}
+
+ )
+ }
+
+ return (
+
+
+
+
{t.clients.title}
+
+ {t.clients.description(activeWorkspace.name)}
+
+
+
+
+
+
+
+
+
+
+ {isLoading ? (
+
+
+
+ ) : clients.length === 0 ? (
+
+
+
{t.clients.noClients}
+
+ {searchQuery ? t.clients.noClientsSearch : t.clients.noClientsAdd}
+
+
+ ) : (
+
+ {clients.map((client) => (
+ -
+
+
{client.name}
+ {client.notes && (
+
+ {client.notes}
+
+ )}
+
+ {t.clients.addedOn}: {formatDate(client.created_at)}
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
setIsCreateModalOpen(false)}
+ onSuccess={fetchClientsList}
+ workspaceId={activeWorkspace.id}
+ />
+
+ setEditClient(null)}
+ onSuccess={fetchClientsList}
+ client={editClient}
+ />
+
+ setDeleteClient(null)}
+ onSuccess={fetchClientsList}
+ client={deleteClient}
+ />
+
+ )
+}
diff --git a/src/pages/Workspaces.tsx b/src/pages/Workspaces.tsx
index 4c8c16a..bdb65d6 100644
--- a/src/pages/Workspaces.tsx
+++ b/src/pages/Workspaces.tsx
@@ -106,6 +106,7 @@ export default function Workspaces() {
ordering={ordering}
setOrdering={setOrdering}
orderingOptions={orderingOptions}
+ searchPlaceholder={t.workspace?.searchPlaceholder}
/>
{isLoading ? (
diff --git a/src/types/client.ts b/src/types/client.ts
new file mode 100644
index 0000000..35d1375
--- /dev/null
+++ b/src/types/client.ts
@@ -0,0 +1,16 @@
+export interface Client {
+ id: string;
+ name: string;
+ notes: string | null;
+ workspace: string;
+ can_delete: boolean;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface PaginatedClientList {
+ count: number;
+ next: string | null;
+ previous: string | null;
+ results: Client[];
+}