feat(client): add client's page + CRUD operations modals

This commit is contained in:
2026-03-13 05:09:55 +08:00
parent 3948505a30
commit bbf7dfad2e
13 changed files with 588 additions and 14 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>

View File

@@ -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>
);
};

View 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 }