feat(client): add client's page + CRUD operations modals
This commit is contained in:
75
src/components/CreateClientModal.tsx
Normal file
75
src/components/CreateClientModal.tsx
Normal file
@@ -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 = (
|
||||
<>
|
||||
<Button variant="outline" onClick={onClose} disabled={isLoading}>
|
||||
{t.clients.cancel}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isLoading || !name.trim()}>
|
||||
{isLoading ? "..." : t.clients.create}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t.clients.modalTitle} footer={footer}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">
|
||||
{t.clients.clientName}
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t.clients.clientNamePlaceholder}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">
|
||||
{t.clients.notes}
|
||||
</label>
|
||||
<TextAreaInput
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder={t.clients.notesPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
61
src/components/DeleteClientModal.tsx
Normal file
61
src/components/DeleteClientModal.tsx
Normal file
@@ -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 = (
|
||||
<>
|
||||
<Button variant="outline" onClick={onClose} disabled={isLoading}>
|
||||
{t.clients.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "..." : t.clients.delete}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={t.clients.deleteConfirmTitle}
|
||||
footer={footer}
|
||||
maxWidth="max-w-sm"
|
||||
>
|
||||
<p className="text-slate-500 dark:text-slate-400">
|
||||
{client ? t.clients.deleteConfirmMessage(client.name) : ""}
|
||||
</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
81
src/components/EditClientModal.tsx
Normal file
81
src/components/EditClientModal.tsx
Normal file
@@ -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 = (
|
||||
<>
|
||||
<Button variant="outline" onClick={onClose} disabled={isLoading}>
|
||||
{t.clients.cancel}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isLoading || !name.trim()}>
|
||||
{isLoading ? "..." : t.clients.saveChanges}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t.clients.editClient} footer={footer}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">
|
||||
{t.clients.clientName}
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t.clients.clientNamePlaceholder}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">
|
||||
{t.clients.notes}
|
||||
</label>
|
||||
<TextAreaInput
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder={t.clients.notesPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||
<div className="relative flex-1">
|
||||
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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<ModalProps> = ({
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className={`bg-white dark:bg-slate-900 rounded-2xl shadow-xl w-full ${maxWidth} overflow-hidden`}
|
||||
<Card
|
||||
className={`w-full ${maxWidth} bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 border-0 shadow-xl overflow-hidden rounded-2xl`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-slate-200 dark:border-slate-800 shrink-0">
|
||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-slate-100">
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-md text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
className="h-8 w-8 rounded-md text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5">{children}</div>
|
||||
@@ -61,7 +65,7 @@ export const Modal: React.FC<ModalProps> = ({
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
18
src/components/ui/TextAreaInput.tsx
Normal file
18
src/components/ui/TextAreaInput.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from "react"
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const TextAreaInput = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={`flex min-h-50 w-full rounded-md border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50 px-3 py-2 text-sm ring-offset-white dark:ring-offset-slate-950 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-500 dark:placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 dark:focus-visible:ring-slate-300 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${className || ""}`}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
TextAreaInput.displayName = "TextAreaInput"
|
||||
|
||||
export { TextAreaInput }
|
||||
Reference in New Issue
Block a user