This file contains the full content of all files in your project. You can use this to manually restore your project.
# Settings to manage and configure a Firebase App Hosting backend.
# https://firebase.google.com/docs/app-hosting/configure
runConfig:
# Increase this value if you'd like to automatically spin up
# more instances in response to increased traffic.
maxInstances: 1
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
import type {NextConfig} from 'next';
const nextConfig: NextConfig = {
/* config options here */
typescript: {
ignoreBuildErrors: true,
},
eslint: {
ignoreDuringBuilds: true,
},
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'placehold.co',
port: '',
pathname: '/**',
},
{
protocol: 'https',
hostname: 'picsum.photos',
port: '',
pathname: '/**',
}
],
},
allowedDevOrigins: ["*.cloudworkstations.dev"],
};
export default nextConfig;
{
"name": "nextn",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"genkit:dev": "genkit start -- tsx src/ai/dev.ts",
"genkit:watch": "genkit start -- tsx --watch src/ai/dev.ts",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@genkit-ai/googleai": "^1.16.0",
"@genkit-ai/next": "1.16.0",
"@hookform/resolvers": "^4.1.3",
"@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-aspect-ratio": "^1.1.0",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-menubar": "^1.1.6",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-radio-group": "^1.2.3",
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slider": "^1.2.3",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-tooltip": "^1.1.8",
"@stripe/react-stripe-js": "^2.7.3",
"@stripe/stripe-js": "^4.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"dotenv": "^16.4.5",
"embla-carousel-react": "^8.1.7",
"firebase": "^10.12.3",
"firebase-admin": "^12.2.0",
"framer-motion": "^11.3.8",
"genkit": "^1.16.0",
"lodash": "^4.17.21",
"lucide-react": "^0.475.0",
"next": "15.3.3",
"papaparse": "^5.4.1",
"patch-package": "^8.0.0",
"react": "^18.3.1",
"react-bootstrap-icons": "^1.11.4",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.54.2",
"recharts": "^2.15.1",
"stripe": "^16.2.0",
"tailwind-merge": "^3.0.1",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/lodash": "^4.17.7",
"@types/node": "^20",
"@types/papaparse": "^5.3.14",
"@types/react": "^18",
"@types/react-dom": "^18",
"genkit-cli": "^1.16.0",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}
'use server';
/**
* @fileOverview An AI flow to generate a list of potential project scopes
* and questions based on a selected room or area in a house.
*/
import {ai} from '@/ai/genkit';
import {
projectScopeInputSchema,
projectScopeOutputSchema,
type ProjectScopeInput,
type ProjectScopeOutput,
} from './schemas';
const projectScopePrompt = ai.definePrompt({
name: 'projectScopePrompt',
input: {schema: projectScopeInputSchema},
output: {schema: projectScopeOutputSchema},
prompt: `You are an expert home renovation project manager. A user has selected a room in their house they want to work on.
Your task is to generate a list of 5-7 common project areas or scopes of work for the specified room: {{{room}}}.
For each scope, provide:
1. A unique, simple, kebab-case ID (e.g., "flooring-installation").
2. A clear title for the work (e.g., "Flooring Installation").
3. A specific question to ask the user to get more details about that item.
Return the list in the 'scopes' array.`,
});
const generateProjectScopeFlow = ai.defineFlow(
{
name: 'generateProjectScopeFlow',
inputSchema: projectScopeInputSchema,
outputSchema: projectScopeOutputSchema,
},
async (input) => {
const {output} = await projectScopePrompt(input);
return output!;
}
);
export async function generateProjectScope(
input: ProjectScopeInput
): Promise {
return await generateProjectScopeFlow(input);
}
/**
* @fileOverview Zod schemas and TypeScript types for AI flows.
*/
import {z} from 'zod';
// Schemas for project-scope-flow.ts
export const projectScopeInputSchema = z.object({
room: z
.string()
.describe(
'The room or area selected by the user (e.g., "Kitchen", "Bathroom").'
),
});
export type ProjectScopeInput = z.infer;
const ScopeItemSchema = z.object({
id: z
.string()
.describe(
'A unique slug-like ID for the scope item (e.g., "cabinet-replacement").'
),
title: z
.string()
.describe('The title of the project area (e.g., "Cabinet Replacement").'),
question: z
.string()
.describe(
'A question to ask the user to gather more details (e.g., "What kind of new cabinets are you interested in?").'
),
});
export const projectScopeOutputSchema = z.object({
scopes: z
.array(ScopeItemSchema)
.describe(
'An array of potential project scopes relevant to the selected room.'
),
});
export type ProjectScopeOutput = z.infer;
'use server';
/**
* @fileOverview A Genkit flow to send verification codes for sign-up and password reset.
* This is a mock implementation and does not actually send emails.
*/
import { ai } from '@/ai/genkit';
import { z } from 'zod';
// In-memory store for codes (for demonstration purposes only)
// In a real application, use a secure, persistent store like Firestore with TTL.
const codeStore: Record = {};
const sendCodeInputSchema = z.object({
email: z.string().email().describe('The email address to send the code to.'),
type: z.enum(['signup', 'reset']).describe('The purpose of the verification code.'),
});
const sendCodeOutputSchema = z.object({
success: z.boolean(),
message: z.string(),
});
const verifyCodeInputSchema = z.object({
email: z.string().email(),
code: z.string().length(6),
type: z.enum(['signup', 'reset']),
});
const verifyCodeOutputSchema = z.boolean();
/**
* MOCK: Generates a 6-digit code, stores it, and simulates sending an email.
*/
const sendVerificationCodeFlow = ai.defineFlow(
{
name: 'sendVerificationCodeFlow',
inputSchema: sendCodeInputSchema,
outputSchema: sendCodeOutputSchema,
},
async ({ email, type }) => {
const code = Math.floor(100000 + Math.random() * 900000).toString();
const expires = Date.now() + 10 * 60 * 1000; // 10 minutes
// Store the code
codeStore[email] = { code, expires, type };
console.log(`
================================================
MOCK EMAIL SENDER
================================================
To: ${email}
Subject: Your Verification Code
Body: Your verification code is: ${code}
Purpose: ${type}
================================================
`);
return {
success: true,
message: 'Verification code sent successfully.',
};
}
);
/**
* MOCK: Verifies a code against the in-memory store.
*/
const verifyCodeFlow = ai.defineFlow(
{
name: 'verifyCodeFlow',
inputSchema: verifyCodeInputSchema,
outputSchema: verifyCodeOutputSchema,
},
async ({ email, code, type }) => {
const stored = codeStore[email];
if (!stored) {
console.error(`Verification failed for ${email}: No code found.`);
return false;
}
if (stored.expires < Date.now()) {
console.error(`Verification failed for ${email}: Code expired.`);
delete codeStore[email];
return false;
}
if (stored.code === code && stored.type === type) {
console.log(`Verification successful for ${email}.`);
delete codeStore[email]; // One-time use
return true;
}
console.error(`Verification failed for ${email}: Code mismatch.`);
return false;
}
);
// Exported wrapper functions for client-side use
export async function sendVerificationCode(input: z.infer) {
return await sendVerificationCodeFlow(input);
}
export async function verifyCode(input: z.infer): Promise {
return await verifyCodeFlow(input);
}
'use server';
/**
* @fileOverview A simple story generation AI flow.
*/
import { ai } from '@/ai/genkit';
import { z } from 'zod';
export const storyInputSchema = z.object({
prompt: z.string().describe('The user prompt for the story'),
});
export type StoryInput = z.infer;
export const storyOutputSchema = z.object({
story: z.string().describe('The generated story'),
});
export type StoryOutput = z.infer;
const storyPrompt = ai.definePrompt({
name: 'storyPrompt',
input: { schema: storyInputSchema },
output: { schema: storyOutputSchema },
prompt: `Write a short story based on the following prompt: {{{prompt}}}`,
});
const storyFlow = ai.defineFlow(
{
name: 'storyFlow',
inputSchema: storyInputSchema,
outputSchema: storyOutputSchema,
},
async (input) => {
const { output } = await storyPrompt(input);
return output!;
}
);
export async function generateStory(input: StoryInput): Promise {
return await storyFlow(input);
}
/**
* @fileoverview This file initializes the Genkit AI provider.
*/
import {genkit} from 'genkit';
import {googleAI} from '@genkit-ai/googleai';
export const ai = genkit({
plugins: [
googleAI(),
],
enableTracingAndMetrics: true,
});
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import Image from 'next/image';
export default function AboutPage() {
return (
About EHG Renovations
Your vision, our expertise. Building trust one project at a time.
Founded on the principles of quality craftsmanship, integrity, and unparalleled customer service, EHG Renovations has grown to be a trusted name in the home improvement industry. We believe that every project is an opportunity to create something beautiful and lasting.
Our team of skilled professionals, from designers to builders, is dedicated to bringing your vision to life. We handle every aspect of your project with meticulous attention to detail, ensuring a seamless process from the initial consultation to the final walkthrough.
Whether it's a minor repair or a major renovation, we are committed to delivering results that not only meet but exceed your expectations. Thank you for considering EHG Renovations for your next project. We look forward to building with you.
);
}
'use client';
import { useForm, type SubmitHandler } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useEstimate } from '@/app/layout';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
const clientInfoSchema = z.object({
name: z.string().min(2, 'Name is required'),
email: z.string().email('Invalid email address'),
phone: z.string().min(10, 'A valid phone number is required'),
});
type ClientInfoForm = z.infer;
export default function ClientInfoStep() {
const { setStep, setFormData, formData } = useEstimate();
const form = useForm({
resolver: zodResolver(clientInfoSchema),
defaultValues: formData.clientInfo || {
name: '',
email: '',
phone: '',
},
});
const onSubmit: SubmitHandler = (data) => {
setFormData({ ...formData, clientInfo: data });
setStep('project-selection');
};
return (
);
}
'use client';
import { useEstimate } from '@/app/layout';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { CheckCircle2 } from 'lucide-react';
import Link from 'next/link';
export default function ConfirmationStep() {
const { formData } = useEstimate();
return (
Thank You, {formData.clientInfo?.name}!
Your estimate request has been successfully submitted. We will review the details and get back to you within 24-48 hours.
A confirmation has been sent to {formData.clientInfo?.email}.
);
}
'use client';
import { useEstimate, type EstimateStep } from '@/app/layout';
const estimateSteps: { step: EstimateStep; label: string }[] = [
{ step: 'client-info', label: 'Client Info' },
{ step: 'project-selection', label: 'Project Selection' },
{ step: 'project-scope', label: 'Project Scope' },
{ step: 'review', label: 'Review & Finalize' },
];
interface EstimateTOCProps {
onNavigate: (step: EstimateStep) => void;
}
export function EstimateTOC({ onNavigate }: EstimateTOCProps) {
const { step: currentStep } = useEstimate();
return (
<>
{estimateSteps.map(({ step, label }) => (
))}
>
);
}
'use client';
import { useEffect } from 'react';
import { useEstimate } from '@/app/layout';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
export default function FinalizingStep() {
const { setStep } = useEstimate();
useEffect(() => {
const timer = setTimeout(() => {
setStep('confirmation');
}, 3000); // Simulate a 3-second processing time
return () => clearTimeout(timer);
}, [setStep]);
return (
Finalizing Your Request
We're compiling your details and submitting them to our team.
Please wait a moment...
);
}
'use client';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useEstimate } from '@/app/layout';
import { generateProjectScope, type ProjectScopeOutput } from '@/ai/flows/project-scope-flow';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Form, FormControl, FormField, FormItem, FormLabel } from '@/components/ui/form';
import { Skeleton } from '@/components/ui/skeleton';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/hooks/use-toast';
type Scope = ProjectScopeOutput['scopes'][0];
export default function ProjectScopeStep() {
const { setStep, setFormData, formData } = useEstimate();
const [scopes, setScopes] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const { toast } = useToast();
const form = useForm<{ scopes: string[]; details: Record }>({
defaultValues: {
scopes: formData.projectScope?.scopes || [],
details: formData.projectScope?.details || {},
},
});
const selectedScopes = form.watch('scopes');
useEffect(() => {
const fetchScope = async () => {
if (!formData.projectSelection?.room) {
toast({
variant: 'destructive',
title: 'Error',
description: 'Project area not selected. Please go back.',
});
setStep('project-selection');
return;
}
setIsLoading(true);
try {
const result = await generateProjectScope({ room: formData.projectSelection.room });
setScopes(result.scopes);
} catch (error) {
console.error(error);
toast({
variant: 'destructive',
title: 'AI Error',
description: 'Could not generate project scopes. Please try again.',
});
} finally {
setIsLoading(false);
}
};
fetchScope();
}, [formData.projectSelection, setStep, toast]);
const onSubmit = (data: { scopes: string[]; details: Record }) => {
setFormData({ ...formData, projectScope: data });
setStep('review');
};
if (isLoading) {
return (
{[...Array(5)].map((_, i) => (
))}
);
}
return (
);
}
'use client';
import { useForm, type SubmitHandler } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useEstimate } from '@/app/layout';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Home, Bath, ChefHat, BedDouble, DraftingCompass } from 'lucide-react';
import { cn } from '@/lib/utils';
const projectSelectionSchema = z.object({
room: z.string({
required_error: 'You need to select a project area.',
}),
});
type ProjectSelectionForm = z.infer;
const roomOptions = [
{ value: 'Kitchen', label: 'Kitchen', icon: ChefHat },
{ value: 'Bathroom', label: 'Bathroom', icon: Bath },
{ value: 'Bedroom', label: 'Bedroom', icon: BedDouble },
{ value: 'General Living Area', label: 'General Living Area', icon: Home },
{ value: 'Other', label: 'Other', icon: DraftingCompass },
];
export default function ProjectSelectionStep() {
const { setStep, setFormData, formData } = useEstimate();
const form = useForm({
resolver: zodResolver(projectSelectionSchema),
defaultValues: formData.projectSelection || {},
});
const onSubmit: SubmitHandler = (data) => {
setFormData({ ...formData, projectSelection: data });
setStep('project-scope');
};
return (
);
}
'use client';
import { useEstimate } from '@/app/layout';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
export default function ReviewStep() {
const { setStep, formData } = useEstimate();
const { clientInfo, projectSelection, projectScope } = formData;
const getScopeTitle = (scopeId: string) => {
// This is a mock function. In a real app, you'd have the scope details available.
// For now, we'll format the ID.
return scopeId.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
};
return (
Step 4: Review & Finalize
Please review the details below before submitting your estimate request.
Client Information
Name: {clientInfo?.name}
Email: {clientInfo?.email}
Phone: {clientInfo?.phone}
Project Details
Project Area: {projectSelection?.room}
Scope of Work
{projectScope?.scopes.length > 0 ? (
{projectScope.scopes.map((scopeId: string) => (
-
{getScopeTitle(scopeId)}
{projectScope.details?.[scopeId] && (
{projectScope.details[scopeId]}
)}
))}
) : (
No specific work items were selected.
)}
);
}
'use client';
import { EstimateProvider, useEstimate } from '@/app/layout';
import ClientInfoStep from './_components/ClientInfoStep';
import ProjectSelectionStep from './_components/ProjectSelectionStep';
import ProjectScopeStep from './_components/ProjectScopeStep';
import ReviewStep from './_components/ReviewStep';
import FinalizingStep from './_components/FinalizingStep';
import ConfirmationStep from './_components/ConfirmationStep';
const EstimateFlow = () => {
const { step } = useEstimate();
const renderStep = () => {
switch (step) {
case 'client-info':
return ;
case 'project-selection':
return ;
case 'project-scope':
return ;
case 'review':
return ;
case 'finalizing':
return ;
case 'confirmation':
return ;
default:
return ;
}
};
return {renderStep()};
};
export default function EstimatePage() {
return ;
}
'use client';
import React from 'react';
import { Card, CardContent } from '@/components/ui/card';
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from '@/components/ui/carousel';
import Image from 'next/image';
import { AspectRatio } from '@/components/ui/aspect-ratio';
const galleryImages = [
{ src: 'https://picsum.photos/seed/gallery1/800/600', alt: 'Modern kitchen renovation', hint: 'modern kitchen' },
{ src: 'https://picsum.photos/seed/gallery2/800/600', alt: 'Luxurious bathroom remodel', hint: 'luxury bathroom' },
{ src: 'https://picsum.photos/seed/gallery3/800/600', alt: 'Spacious living room addition', hint: 'living room' },
{ src: 'https://picsum.photos/seed/gallery4/800/600', alt: 'Custom deck and patio', hint: 'deck patio' },
{ src: 'https://picsum.photos/seed/gallery5/800/600', alt: 'Finished basement entertainment area', hint: 'finished basement' },
{ src: 'https://picsum.photos/seed/gallery6/800/600', alt: 'Exterior siding and window replacement', hint: 'house exterior' },
];
export default function GalleryPage() {
return (
Our Work
A collection of our finest projects, showcasing our commitment to quality and craftsmanship.
{galleryImages.map((image, index) => (
))}
);
}
// This file is intentionally left blank to resolve a routing conflict.
export default function ConflictingUnlockPage() {
return null;
}
'use server';
// NOTE: Stripe is not fully configured. This is a placeholder.
// The secret key should be in a .env.local file.
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY || 'sk_test_...'; // Replace with a default test key if needed
let stripe: any;
try {
// Dynamically import Stripe to handle cases where it might not be installed.
const Stripe = require('stripe');
stripe = new Stripe(STRIPE_SECRET_KEY, {
apiVersion: '2024-06-20',
});
} catch (e) {
console.error("Stripe SDK not found. All payment actions will fail.");
stripe = null;
}
// Creates a Payment Intent to authorize a charge, but does not capture it yet.
export async function createPaymentIntent(amount: number) {
if (!stripe) {
const errorMsg = 'Stripe SDK is not initialized.';
console.error(errorMsg);
return { error: errorMsg };
}
if (!process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) {
const errorMsg = 'Stripe publishable key is not set in environment variables.';
console.error(errorMsg);
return { error: errorMsg };
}
try {
const paymentIntent = await stripe.paymentIntents.create({
amount: amount,
currency: 'usd',
automatic_payment_methods: {
enabled: true,
},
capture_method: 'manual', // Authorize only
});
return {
clientSecret: paymentIntent.client_secret,
paymentIntentId: paymentIntent.id,
publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
};
} catch (error) {
console.error('Error creating PaymentIntent:', error);
// @ts-ignore
return { error: error.message || 'Failed to create PaymentIntent.' };
}
}
// Captures the authorized amount on a Payment Intent.
export async function capturePaymentIntent(paymentIntentId: string, amountToCapture: number) {
if (!stripe) {
const errorMsg = 'Stripe SDK is not initialized.';
console.error(errorMsg);
return { error: errorMsg };
}
try {
const capturedIntent = await stripe.paymentIntents.capture(paymentIntentId, {
amount_to_capture: amountToCapture,
});
return { success: true, intent: capturedIntent };
} catch (error) {
console.error('Error capturing PaymentIntent:', error);
// @ts-ignore
return { error: error.message || 'Failed to capture PaymentIntent.' };
}
}
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Textarea } from '@/components/ui/textarea';
import { generateStory } from '@/ai/flows/story-flow';
export default function AiStoryPage() {
const [prompt, setPrompt] = useState('');
const [story, setStory] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleGenerate = async () => {
if (!prompt) return;
setIsLoading(true);
setStory('');
try {
const result = await generateStory({ prompt });
setStory(result.story);
} catch (error) {
console.error('Error generating story:', error);
setStory('Sorry, something went wrong. Please try again.');
} finally {
setIsLoading(false);
}
};
return (
AI Story Generator
Enter a prompt and let the AI write a short story for you.
);
}
'use client';
import React, { useState, useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { usePos } from '@/contexts/PosContext';
import { format, parseISO } from 'date-fns';
import { PlusCircle, PencilFill, TrashFill, Info, CheckCircleFill } from 'react-bootstrap-icons';
import { TimeClockDialog } from '@/components/time-clock/TimeClockDialog';
import type { TimeClockEntry, TimeEditRequest } from '@/lib/types';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { DenialReasonDialog } from '@/components/time-clock/DenialReasonDialog';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
export default function AdminTimeClockPage() {
const {
timeClockEntries,
deleteTimeClockEntry,
timeEditRequests,
approveTimeEdit,
denyTimeEdit
} = usePos();
const [editingEntry, setEditingEntry] = useState(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [activeRequest, setActiveRequest] = useState(null);
const [isNoteDialogOpen, setIsNoteDialogOpen] = useState(false);
const [noteDialogMode, setNoteDialogMode] = useState<'approve' | 'deny'>('deny');
const handleAddNew = () => {
setEditingEntry(null);
setIsDialogOpen(true);
};
const handleEdit = (entry: TimeClockEntry) => {
setEditingEntry(entry);
setIsDialogOpen(true);
};
const handleDelete = (entryId: string) => {
deleteTimeClockEntry(entryId);
}
const handleOpenNoteDialog = (request: TimeEditRequest, mode: 'approve' | 'deny') => {
setActiveRequest(request);
setNoteDialogMode(mode);
setIsNoteDialogOpen(true);
};
const handleConfirmNote = (requestId: string, reason: string) => {
if (noteDialogMode === 'approve') {
approveTimeEdit(requestId, reason);
} else {
denyTimeEdit(requestId, reason);
}
}
const sortedEntries = [...timeClockEntries].sort((a, b) => new Date(b.clockInTime).getTime() - new Date(a.clockInTime).getTime());
const pendingRequests = useMemo(() => {
return timeEditRequests.filter(req => req.status === 'pending');
}, [timeEditRequests]);
const getStatusIcon = (status?: 'pending' | 'approved' | 'denied') => {
switch(status) {
case 'pending': return ;
case 'approved': return ;
case 'denied': return ;
default: return ;
}
}
const formatDuration = (startTime: string, endTime: string | null): string => {
if (!endTime) return '-';
const start = parseISO(startTime);
const end = parseISO(endTime);
const diff = end.getTime() - start.getTime();
if (diff < 0) return '00:00:00';
const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
};
return (
{pendingRequests.length > 0 && (
Pending Time Edit Requests
Review and approve or deny employee time edit requests.
Employee
Original Times
Requested Times
Reason
Initials
Actions
{pendingRequests.map(request => (
{request.employeeName}
{request.entries.map(e => (
IN: {format(parseISO(e.originalClockIn), 'MM/dd hh:mm a')}
OUT: {e.originalClockOut ? format(parseISO(e.originalClockOut), 'MM/dd hh:mm a') : 'N/A'}
))}
{request.entries.map(e => (
IN: {format(parseISO(e.requestedClockIn), 'MM/dd hh:mm a')}
OUT: {e.requestedClockOut ? format(parseISO(e.requestedClockOut), 'MM/dd hh:mm a') : 'N/A'}
))}
{request.reason}
{request.initials}
))}
)}
All Time Clock Entries
Employee
Clock In
Clock Out
Duration
Status
Approved
Pending
Denied
Actions
{sortedEntries.map(entry => (
{entry.employeeName}
{format(parseISO(entry.clockInTime), 'MM/dd/yyyy hh:mm a')}
{entry.clockOutTime ? format(parseISO(entry.clockOutTime), 'MM/dd/yyyy hh:mm a') : 'Still Clocked In'}
{formatDuration(entry.clockInTime, entry.clockOutTime)}
{getStatusIcon(entry.editRequest?.status)}
Are you sure?
This action cannot be undone. This will permanently delete the time clock entry.
Cancel
handleDelete(entry.id)}>
Delete
))}
{sortedEntries.length === 0 && (
No time clock entries found.
)}
{activeRequest && (
)}
);
}
'use client';
import React, { useState, useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { usePos } from '@/contexts/PosContext';
import type { User } from '@/lib/types';
import { PlusCircle, Pencil, Trash2 } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { UserDialog } from '@/components/users/UserDialog';
import { Badge } from '@/components/ui/badge';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { MoreHorizontal } from "lucide-react"
export default function AdminUsersPage() {
const { posUsers, deletePosUser } = usePos();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingUser, setEditingUser] = useState(null);
const handleAddNew = () => {
setEditingUser(null);
setIsDialogOpen(true);
};
const handleEdit = (user: User) => {
setEditingUser(user);
setIsDialogOpen(true);
};
const handleDelete = (userId: string) => {
deletePosUser(userId);
};
const getStatusVariant = (status: User['activityStatus']) => {
if (status === 'active') return 'default';
if (status === 'inactive') return 'secondary';
return 'outline';
};
return (
User Management
Add, edit, and manage system users and their roles.
Name
Primary Role
Status
Email
Actions
{posUsers.map(user => (
{user.name}
{user.role}
{user.activityStatus}
{user.email}
Actions
handleEdit(user)}>
Edit
e.preventDefault()} className="text-destructive">
Delete
Are you sure?
This will permanently delete the user.
Cancel
handleDelete(user.id)}>Delete
))}
{posUsers.length === 0 && (
No users found.
)}
);
}
// This file is intentionally left blank to resolve a routing conflict.
export default function ConflictingCrmCustomerAccountsPage() {
return null;
}
// This file is intentionally left blank to resolve a routing conflict.
export default function ConflictingCrmCustomerEstimatesPage() {
return null;
}
'use client';
import React, { useState, useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { usePos } from '@/contexts/PosContext';
import type { Customer } from '@/lib/types';
import { Search, PlusCircle, Pencil, Trash2 } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { CustomerDialog } from '@/components/customers/CustomerDialog';
export default function CrmCustomerPage() {
const { customers, deleteCustomer } = usePos();
const [searchQuery, setSearchQuery] = useState('');
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingCustomer, setEditingCustomer] = useState(null);
const filteredCustomers = useMemo(() => {
return customers.filter(c =>
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.phone.includes(searchQuery)
);
}, [customers, searchQuery]);
const handleAddNew = () => {
setEditingCustomer(null);
setIsDialogOpen(true);
};
const handleEdit = (customer: Customer) => {
setEditingCustomer(customer);
setIsDialogOpen(true);
};
const handleDelete = (customerId: string) => {
deleteCustomer(customerId);
};
return (
Customer Management
View, add, edit, and manage all customers and clients.
setSearchQuery(e.target.value)}
className="pl-8"
/>
Customer ID
Name
Type
Email
Phone
Actions
{filteredCustomers.map(customer => (
{customer.customerId}
{customer.name}
{customer.type}
{customer.email}
{customer.phone}
Are you sure?
This action cannot be undone. This will permanently delete the customer record.
Cancel
handleDelete(customer.id)}>Delete
))}
{filteredCustomers.length === 0 && (
No customers found.
)}
);
}
'use client';
import {
Activity,
CreditCard,
DollarSign,
Users,
} from 'lucide-react';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { CalendarWidget } from '@/components/dashboard/CalendarWidget';
import { WelcomeDialog } from '@/components/dashboard/WelcomeDialog';
export default function DashboardPage() {
return (
<>
Total Revenue
$45,231.89
+20.1% from last month
Subscriptions
+2350
+180.1% from last month
Sales
+12,234
+19% from last month
Active Now
+573
+201 since last hour
Recent Sales
You made 265 sales this month.
Customer
Type
Status
Date
Amount
Liam Johnson
liam@example.com
Sale
Successful
2023-06-23
$250.00
>
);
}
'use client';
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { File, Upload } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import OasisBackground from '@/components/documents/OasisBackground';
import ManageTab from '@/components/documents/ManageTab';
import UploadTab from '@/components/documents/UploadTab';
import { Button } from '@/components/ui/button';
const TABS = [
{ id: 'manage', label: 'Manage Files', icon: File },
{ id: 'upload', label: 'Upload Files', icon: Upload }
];
// Mock file handler hook
const useFileHandler = () => {
const [uploadingFiles, setUploadingFiles] = useState([]);
const [completedFiles, setCompletedFiles] = useState([
{ id: '1', name: 'Johnson Kitchen Remodel Plan.pdf', size: 2300000, type: 'application/pdf', category: 'Jobs' },
{ id: '2', name: 'Living Room Inspiration.jpg', size: 1200000, type: 'image/jpeg', category: 'Jobs' },
{ id: '3', name: 'Vendor Contract - Plumbing.docx', size: 45000, type: 'application/msword', category: 'Vendors' },
]);
const handleFileSelect = (selectedFiles: File[]) => {
// Mock upload process
};
const removeFile = (fileId: string) => {
setCompletedFiles(prev => prev.filter(f => f.id !== fileId));
};
return { getUploadingFiles: () => uploadingFiles, getCompletedFiles: () => completedFiles, handleFileSelect, removeFile };
};
export default function DocumentOasisPage() {
const [activeTab, setActiveTab] = useState('manage');
const [activeView, setActiveView] = useState({ parent: 'File Management', child: 'All Files' });
const { getUploadingFiles, getCompletedFiles, handleFileSelect, removeFile } = useFileHandler();
const handleCategorySelect = ({ parent, child }: { parent: string, child: string }) => {
setActiveView({ parent, child });
};
const getFilesForView = () => {
const allFiles = getCompletedFiles();
if (activeView.child === 'All Files' || !activeView.child) return allFiles;
// This logic would need to be expanded for sub-items
return allFiles.filter(file => file.category === activeView.child);
};
return (
{activeTab === 'upload' ? 'Upload Center' : activeView.child}
{TABS.map(tab => (
))}
{activeTab === 'upload' && (
)}
{activeTab === 'manage' && (
)}
);
}
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 5% 10%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 120 100% 50%;
--primary-foreground: 120 100% 5%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 120 100% 50% / 0.5;
--input: 240 3.7% 15.9%;
--ring: 120 100% 50%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--sidebar-background: 240 10% 3.9%;
--sidebar-foreground: 0 0% 98%;
--sidebar-primary: 120 100% 50%;
--sidebar-primary-foreground: 120 100% 5%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 0 0% 98%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 120 100% 50%;
}
}
@layer base {
* {
@apply border-border;
}
html {
/* This creates a buffer at the bottom of the scroll area for FABs */
scroll-padding-bottom: 80px;
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
@apply bg-background text-foreground;
}
}
/* Custom print styles for receipt */
@media print {
body {
background-color: white !important;
}
.no-print {
display: none !important;
}
.print-only {
display: block !important;
position: absolute;
left: 0;
top: 0;
width: 100%;
}
}
import { ImageResponse } from 'next/og'
// Route segment config
export const runtime = 'edge'
// Image metadata
export const size = {
width: 32,
height: 32,
}
export const contentType = 'image/png'
// Image generation
export default function Icon() {
return new ImageResponse(
(
// ImageResponse JSX element
E
),
// ImageResponse options
{
// For convenience, we can re-use the exported icons size metadata
// config to also set the ImageResponse's width and height.
...size,
}
)
}
'use client';
import React, { useState, useMemo, useRef, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { usePos } from '@/contexts/PosContext';
import type { Tool } from '@/lib/types';
import { Search, Filter, Upload, Download, PlusCircle, Pencil, Trash2 } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuCheckboxItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { groupBy } from 'lodash';
import Papa from 'papaparse';
import { AssetDialog, AssetType, Asset } from '@/components/inventory/AssetDialog';
import { Skeleton } from '@/components/ui/skeleton';
export default function InventoryEquipmentPage() {
const {
tools,
deleteAsset,
isLoading,
loadInitialData,
} = usePos();
const { toast } = useToast();
const fileInputRef = useRef(null);
useEffect(() => {
loadInitialData();
}, [loadInitialData]);
// Dialog State
const [isAssetDialogOpen, setIsAssetDialogOpen] = useState(false);
const [editingAsset, setEditingAsset] = useState(null);
const [assetTypeForDialog, setAssetTypeForDialog] = useState('equipment');
// State for Equipment Tab
const [toolSearchQuery, setToolSearchQuery] = useState('');
const [toolSelectedStatuses, setToolSelectedStatuses] = useState([]);
const handleAddNew = (type: AssetType) => {
setEditingAsset(null);
setAssetTypeForDialog(type);
setIsAssetDialogOpen(true);
};
const handleEdit = (asset: Asset, type: AssetType) => {
setEditingAsset(asset);
setAssetTypeForDialog(type);
setIsAssetDialogOpen(true);
};
const handleDelete = (assetId: string, type: AssetType) => {
deleteAsset(assetId, type);
};
const handleExport = () => {
const csv = Papa.unparse(tools);
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'equipment.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({ title: 'Export Successful', description: `Data has been downloaded as equipment.csv.` });
};
const handleImportClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = (event: React.ChangeEvent) => {
// This functionality will be moved to PosContext in a future step for bulk additions
toast({ title: "Coming Soon", description: "Bulk import functionality will be enabled shortly."});
};
// Memoized categories and statuses for filters
const toolStatuses = useMemo(() => Array.from(new Set(tools.map(t => t.status))), [tools]);
// Memoized filtering logic for Equipment
const filteredTools = useMemo(() => {
return tools.filter(tool => {
const matchesStatus = toolSelectedStatuses.length === 0 || toolSelectedStatuses.includes(tool.status);
const matchesSearch = tool.name.toLowerCase().includes(toolSearchQuery.toLowerCase()) ||
tool.serialNumber.toLowerCase().includes(toolSearchQuery.toLowerCase());
return matchesStatus && matchesSearch;
});
}, [tools, toolSearchQuery, toolSelectedStatuses]);
const groupedAndFilteredTools = useMemo(() => {
const byType = groupBy(filteredTools, 'type');
return Object.keys(byType).sort().map(type => ({
type,
categories: groupBy(byType[type], 'category'),
}));
}, [filteredTools]);
const getToolStatusVariant = (status: Tool['status']): 'default' | 'destructive' | 'secondary' => {
if (status === 'Under Maintenance') return 'destructive';
if (status === 'In Use') return 'secondary';
return 'default';
};
const renderSkeletons = () => (
Array.from({ length: 3 }).map((_, i) => (
Name
Serial Number
Status
Actions
{Array.from({ length: 2 }).map((_, j) => (
))}
))
);
return (
Equipment Management
View and manage your company's tools and equipment.
setToolSearchQuery(e.target.value)}
className="pl-8"
/>
Filter by Status
setToolSelectedStatuses([])}>All Statuses
{toolStatuses.map(status => (
{
setToolSelectedStatuses(prev =>
checked ? [...prev, status] : prev.filter(s => s !== status)
)
}}
>
{status}
))}
g.type)}>
{isLoading ? renderSkeletons() : groupedAndFilteredTools.map(group => (
{group.type}
{Object.keys(group.categories).sort().map(category => (
{category}
Name
Serial Number
Status
Actions
{group.categories[category].map(tool => (
{tool.name}
{tool.serialNumber}
{tool.status}
Are you sure? This will permanently delete the equipment item.
Cancel handleDelete(tool.id, 'equipment')}>Delete
))}
))}
))}
{!isLoading && filteredTools.length === 0 && (
No equipment found.
)}
);
}
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
// This page now acts as a redirect to the default inventory view (products).
export default function InventoryRedirectPage() {
const router = useRouter();
useEffect(() => {
router.replace('/inventory/products');
}, [router]);
return (
Redirecting to inventory...
);
}
'use client';
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { usePos } from '@/contexts/PosContext';
import type { Product } from '@/lib/types';
import { Plus, Minus, Search, Filter, Upload, Download, PlusCircle, Pencil, Trash2 } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuCheckboxItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { groupBy } from 'lodash';
import Papa from 'papaparse';
import { AssetDialog, AssetType, Asset } from '@/components/inventory/AssetDialog';
import { Skeleton } from '@/components/ui/skeleton';
export default function InventoryProductsPage() {
const {
products: contextProducts,
updateProductStock,
deleteAsset,
isLoading,
loadInitialData,
} = usePos();
const [products, setProducts] = useState(contextProducts || []);
const { toast } = useToast();
const fileInputRef = useRef(null);
// Dialog State
const [isAssetDialogOpen, setIsAssetDialogOpen] = useState(false);
const [editingAsset, setEditingAsset] = useState(null);
const [assetTypeForDialog, setAssetTypeForDialog] = useState('products');
// State for Products Tab
const [productSearchQuery, setProductSearchQuery] = useState('');
const [productSelectedCategories, setProductSelectedCategories] = useState([]);
useEffect(() => {
loadInitialData();
}, [loadInitialData]);
useEffect(() => {
if (contextProducts) {
setProducts(contextProducts);
}
}, [contextProducts]);
const handleAddNew = (type: AssetType) => {
setEditingAsset(null);
setAssetTypeForDialog(type);
setIsAssetDialogOpen(true);
};
const handleEdit = (asset: Asset, type: AssetType) => {
setEditingAsset(asset);
setAssetTypeForDialog(type);
setIsAssetDialogOpen(true);
};
const handleDelete = (assetId: string, type: AssetType) => {
deleteAsset(assetId, type);
};
const handleStockChange = (productId: string, newStock: number) => {
if (newStock < 0) return;
setProducts(prev =>
prev.map(p => (p.id === productId ? { ...p, stock: newStock } : p))
);
};
const handleSaveChanges = async () => {
try {
await updateProductStock(products);
toast({
title: "Inventory Saved",
description: "Your stock levels have been successfully updated.",
});
} catch (error) {
toast({
variant: "destructive",
title: "Save Failed",
description: "Could not update stock levels. Please try again.",
});
}
}
const handleExport = () => {
const csv = Papa.unparse(products);
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'products.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({ title: 'Export Successful', description: `Data has been downloaded as products.csv.` });
};
const handleImportClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = (event: React.ChangeEvent) => {
// This functionality will be moved to PosContext in a future step for bulk additions
toast({ title: "Coming Soon", description: "Bulk import functionality will be enabled shortly."});
};
// Memoized categories for filters
const productCategories = useMemo(() => Array.from(new Set((products || []).map(p => p.category))), [products]);
// Memoized filtering logic for Products
const filteredProducts = useMemo(() => {
if (!products) return [];
return products.filter(product => {
const matchesCategory = productSelectedCategories.length === 0 || productSelectedCategories.includes(product.category);
const matchesSearch = product.name.toLowerCase().includes(productSearchQuery.toLowerCase());
return matchesCategory && matchesSearch;
});
}, [products, productSearchQuery, productSelectedCategories]);
const groupedProducts = useMemo(() => {
if (!filteredProducts) return null;
if (productSelectedCategories.length > 0) return null;
return groupBy(filteredProducts, 'category');
}, [filteredProducts, productSelectedCategories]);
// Badge variants
const getStockBadgeVariant = (stock: number): 'default' | 'destructive' | 'secondary' => {
if (stock === 0) return 'destructive';
if (stock < 10) return 'secondary';
return 'default';
};
const renderProductSkeletons = () => (
{Array.from({ length: 5 }).map((_, i) => (
))}
);
return (
Product Inventory
View and manage stock levels for your products.
setProductSearchQuery(e.target.value)}
className="pl-8"
/>
Filter by Category
setProductSelectedCategories([])}>
All Categories
{productCategories.map(category => (
{
setProductSelectedCategories(prev =>
checked ? [...prev, category] : prev.filter(c => c !== category)
)
}}
>
{category}
))}
Product
Category
Price
Current Stock
Manage Stock
Actions
{isLoading ? renderProductSkeletons() : (
{groupedProducts ? (
Object.entries(groupedProducts).map(([category, products]) => (
{category}
{products.map(product => (
{product.name}
{product.category}
${product.price.toFixed(2)}
{product.stock} in stock
handleStockChange(product.id, parseInt(e.target.value, 10) || 0)} className="w-20 text-center" />
Are you sure? This will permanently delete the product.
Cancel handleDelete(product.id, 'products')}>Delete
))}
))
) : (
filteredProducts.map(product => (
{product.name}
{product.category}
${product.price.toFixed(2)}
{product.stock} in stock
handleStockChange(product.id, parseInt(e.target.value, 10) || 0)} className="w-20 text-center" />
Are you sure? This will permanently delete the product.
Cancel handleDelete(product.id, 'products')}>Delete
))
)}
{filteredProducts.length === 0 && !isLoading && (
No products found.
)}
)}
);
}
'use client';
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { usePos } from '@/contexts/PosContext';
import type { Service } from '@/lib/types';
import { Search, Filter, Upload, Download, PlusCircle, Pencil, Trash2 } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuCheckboxItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { groupBy } from 'lodash';
import Papa from 'papaparse';
import { AssetDialog, AssetType, Asset } from '@/components/inventory/AssetDialog';
import { Skeleton } from '@/components/ui/skeleton';
export default function InventoryServicesPage() {
const {
services,
deleteAsset,
isLoading,
loadInitialData,
} = usePos();
const { toast } = useToast();
const fileInputRef = useRef(null);
useEffect(() => {
loadInitialData();
}, [loadInitialData]);
// Dialog State
const [isAssetDialogOpen, setIsAssetDialogOpen] = useState(false);
const [editingAsset, setEditingAsset] = useState(null);
const [assetTypeForDialog, setAssetTypeForDialog] = useState('services');
// State for Services Tab
const [serviceSearchQuery, setServiceSearchQuery] = useState('');
const [serviceSelectedCategories, setServiceSelectedCategories] = useState([]);
const handleAddNew = (type: AssetType) => {
setEditingAsset(null);
setAssetTypeForDialog(type);
setIsAssetDialogOpen(true);
};
const handleEdit = (asset: Asset, type: AssetType) => {
setEditingAsset(asset);
setAssetTypeForDialog(type);
setIsAssetDialogOpen(true);
};
const handleDelete = (assetId: string, type: AssetType) => {
deleteAsset(assetId, type);
};
const handleExport = () => {
const csv = Papa.unparse(services);
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'services.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({ title: 'Export Successful', description: `Data has been downloaded as services.csv.` });
};
const handleImportClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = (event: React.ChangeEvent) => {
// This functionality will be moved to PosContext in a future step for bulk additions
toast({ title: "Coming Soon", description: "Bulk import functionality will be enabled shortly."});
};
// Memoized categories and statuses for filters
const serviceCategories = useMemo(() => Array.from(new Set(services.map(s => s.category))), [services]);
// Memoized filtering logic for Services
const filteredServices = useMemo(() => {
return services.filter(service => {
const matchesCategory = serviceSelectedCategories.length === 0 || serviceSelectedCategories.includes(service.category);
const matchesSearch = service.name.toLowerCase().includes(serviceSearchQuery.toLowerCase());
return matchesCategory && matchesSearch;
});
}, [services, serviceSearchQuery, serviceSelectedCategories]);
const groupedServices = useMemo(() => {
if (serviceSelectedCategories.length > 0) return null;
return groupBy(filteredServices, 'category');
}, [filteredServices, serviceSelectedCategories]);
const getServiceStatusVariant = (status: Service['status']): 'default' | 'destructive' | 'secondary' => {
if (status === 'inactive') return 'destructive';
if (status === 'pending' || status === 'needs updated') return 'secondary';
return 'default';
};
const renderSkeletons = () => (
{Array.from({ length: 5 }).map((_, i) => (
))}
);
return (
Service Management
View and manage your company's services.
setServiceSearchQuery(e.target.value)}
className="pl-8"
/>
Filter by Category
setServiceSelectedCategories([])}>
All Categories
{serviceCategories.map(category => (
{
setServiceSelectedCategories(prev =>
checked ? [...prev, category] : prev.filter(c => c !== category)
)
}}
>
{category}
))}
Service Name
Category
Description
Status
Price
Actions
{isLoading ? renderSkeletons() : (
{groupedServices ? (
Object.entries(groupedServices).map(([category, services]) => (
{category}
{services.map(service => (
{service.name}
{service.category}
{service.description}
{service.status.replace('needs updated', 'Needs Update')}
${service.price.toFixed(2)}
Are you sure? This will permanently delete the service.
Cancel handleDelete(service.id, 'services')}>Delete
))}
))
) : (
filteredServices.map(service => (
{service.name}
{service.category}
{service.description}
{service.status.replace('needs updated', 'Needs Update')}
${service.price.toFixed(2)}
Are you sure? This will permanently delete the service.
Cancel handleDelete(service.id, 'services')}>Delete
))
)}
{filteredServices.length === 0 && !isLoading && (
No services found.
)}
)}
);
}
'use client';
import { Poppins } from "next/font/google";
import "./globals.css";
import { Toaster } from "@/components/ui/toaster";
import { PosContextProvider } from "@/contexts/PosContext";
import { ReactNode, useState, createContext, useContext } from 'react';
import "@/components/layout/fab.css";
import { AuthProvider } from "@/contexts/AuthContext";
import { CrmHeader } from '@/components/layout/CrmHeader';
import { CrmMenubar } from '@/components/layout/CrmMenubar';
import { CrmSidebar } from '@/components/layout/CrmSidebar';
import { ScrollArea } from '@/components/ui/scroll-area';
import { PublicHeader } from '@/components/layout/PublicHeader';
import { PublicFooter } from '@/components/layout/PublicFooter';
import { FabContainer } from '@/components/layout/FabContainer';
import { usePathname } from "next/navigation";
// CONTEXT DEFINITION FOR ESTIMATE/FABs
export type EstimateStep = 'client-info' | 'project-selection' | 'project-scope' | 'review' | 'finalizing' | 'confirmation';
export type ActiveMenu = 'help' | 'menu' | null;
interface EstimateContextType {
step: EstimateStep;
setStep: (step: EstimateStep) => void;
formData: any;
setFormData: (data: any) => void;
activeMenu: ActiveMenu;
setActiveMenu: (menu: ActiveMenu) => void;
}
const EstimateContext = createContext(null);
export const useEstimate = () => {
const context = useContext(EstimateContext);
if (!context) {
throw new Error('useEstimate must be used within an EstimateProvider');
}
return context;
};
export function EstimateProvider({ children }: { children: ReactNode }) {
const [step, setStep] = useState('client-info');
const [formData, setFormData] = useState({});
const [activeMenu, setActiveMenu] = useState(null);
return (
{children}
);
}
const poppins = Poppins({
subsets: ["latin"],
weight: ["400", "600", "700", "900"],
});
const AppLayout = ({ children }: { children: ReactNode }) => {
const pathname = usePathname();
const isCrmRoute = pathname.startsWith('/crm');
const isPosRoute = pathname.startsWith('/pos');
if (isCrmRoute) {
return (
{children}
);
}
if (isPosRoute) {
return (
{children}
)
}
// Fallback to public layout
return (
<>
{children}
>
);
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
{children}
);
}
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
export default function MarketingPage() {
return (
Marketing
Manage your marketing campaigns and outreach.
Marketing - Coming Soon!
);
}
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
// This page now acts as a redirect to the main homepage within the correct layout.
export default function RootPage() {
const router = useRouter();
useEffect(() => {
router.replace('/home');
}, [router]);
return (
Redirecting to homepage...
);
}
'use client';
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { useLocalStorage } from '@/hooks/use-local-storage';
import { Button } from '@/components/ui/button';
import { useToast } from '@/hooks/use-toast';
import Image from 'next/image';
export default function ProfilePage() {
const [widgetStyles, setWidgetStyles] = useLocalStorage<{[key: string]: { bgColor: string } }>('dashboard-widget-styles', {});
const { toast } = useToast();
const handleClearPreferences = () => {
setWidgetStyles({});
toast({
title: 'Preferences Cleared',
description: 'Your dashboard widget styles have been reset to default.',
});
};
const handleConnection = (provider: 'google' | 'microsoft') => {
toast({
title: 'Coming Soon!',
description: `Connecting with ${provider} will be enabled in a future update.`,
});
};
const savedPreferences = Object.entries(widgetStyles).filter(([, style]) => style.bgColor);
return (
My Profile
Manage your personal settings and preferences.
My Connections
Manage your connected accounts for signing in.
Google
Not connected (Authentication disabled)
Microsoft
Not connected
Styles & Themes
Manage your personalized appearance settings for the CRM.
Saved Widget Preferences
{savedPreferences.length > 0 ? (
{savedPreferences.map(([widgetId]) => (
-
Custom background for: {widgetId.replace(/_/g, ' ')}
))}
) : (
You have no saved widget preferences.
)}
);
}
import { FileExplorer } from '@/components/projects/FileExplorer';
export default function ProjectsPage() {
return (
);
}
'use client';
import React, { useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { usePos } from '@/contexts/PosContext';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend } from 'recharts';
import { format, subDays, startOfDay, endOfDay, isWithinInterval, parseISO } from 'date-fns';
import { PiggyBankFill, CreditCardFill, PersonPlusFill, CashStack } from 'react-bootstrap-icons';
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#AF19FF'];
export default function ReportingPage() {
const { transactions, customers } = usePos();
const analyticsData = useMemo(() => {
const totalRevenue = transactions.reduce((acc, t) => acc + t.total, 0);
const totalSales = transactions.length;
const averageSale = totalSales > 0 ? totalRevenue / totalSales : 0;
// Sales over the last 7 days
const last7Days = Array.from({ length: 7 }, (_, i) => subDays(new Date(), i)).reverse();
const salesByDay = last7Days.map(day => {
const dayStart = startOfDay(day);
const dayEnd = endOfDay(day);
const dayTransactions = transactions.filter(t => isWithinInterval(parseISO(t.timestamp), { start: dayStart, end: dayEnd }));
const dailyRevenue = dayTransactions.reduce((acc, t) => acc + t.total, 0);
return {
date: format(day, 'MMM d'),
revenue: dailyRevenue,
};
});
// Top selling products
const productSales = transactions
.flatMap(t => t.tab.cartItems)
.reduce((acc, item) => {
acc[item.name] = (acc[item.name] || 0) + item.quantity;
return acc;
}, {} as { [key: string]: number });
const topProducts = Object.entries(productSales)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([name, quantity]) => ({ name, quantity }));
const recentTransactions = [...transactions].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()).slice(0, 10);
return {
totalRevenue,
totalSales,
averageSale,
totalCustomers: customers.length,
salesByDay,
topProducts,
recentTransactions,
};
}, [transactions, customers]);
return (
Total Revenue
${analyticsData.totalRevenue.toFixed(2)}
Lifetime revenue
Total Sales
+{analyticsData.totalSales}
Lifetime transactions
Average Sale Value
${analyticsData.averageSale.toFixed(2)}
Average per transaction
Total Customers
{analyticsData.totalCustomers}
Total customers in system
Sales Overview
Revenue from the last 7 days.
`$${value}`} />
`$${value.toFixed(2)}`} />
Top Selling Products
Top 5 products by quantity sold.
`${name} ${(percent * 100).toFixed(0)}%`}
>
{analyticsData.topProducts.map((entry, index) => (
|
))}
[`${value} units`, name]}/>
Recent Transactions
The 10 most recent transactions.
Customer
Date
Amount
{analyticsData.recentTransactions.map(transaction => (
{transaction.tab.customer?.name || 'No Customer'}
{format(parseISO(transaction.timestamp), 'MM/dd/yyyy hh:mm a')}
${transaction.total.toFixed(2)}
))}
{analyticsData.recentTransactions.length === 0 && (
No transactions yet.
)}
);
}
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
export default function DevicesPage() {
return (
Device Management
Manage connected hardware like printers, scanners, and card readers.
Device Management - Coming Soon!
);
}
'use client';
import React, { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useToast } from '@/hooks/use-toast';
import { kebabCase, startCase } from 'lodash';
import Link from 'next/link';
import { ArrowLeft } from 'lucide-react';
import { useParams } from 'next/navigation';
import { useLocalStorage } from '@/hooks/use-local-storage';
import { IntegrationIcon } from '../page';
export default function IntegrationDetailPage() {
const params = useParams();
const slug = Array.isArray(params.slug) ? params.slug[0] : params.slug;
const [apiKey, setApiKey] = useState('');
const [secretKey, setSecretKey] = useState('');
const [connectedIntegrations, setConnectedIntegrations] = useLocalStorage('connected-integrations', []);
const { toast } = useToast();
const isConnected = connectedIntegrations.includes(slug as string);
const integrationName = startCase(slug);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!apiKey || !secretKey) {
toast({
variant: 'destructive',
title: 'Missing Information',
description: 'Please provide both an API Key and a Secret Key.',
});
return;
}
// Simulate API call to verify and save credentials
console.log(`Connecting ${integrationName} with API Key: ${apiKey} and Secret Key: ${secretKey}`);
if (!connectedIntegrations.includes(slug as string)) {
setConnectedIntegrations([...connectedIntegrations, slug as string]);
}
toast({
title: 'Connection Successful',
description: `Successfully connected to ${integrationName}.`,
});
};
const handleDisconnect = () => {
setApiKey('');
setSecretKey('');
setConnectedIntegrations(connectedIntegrations.filter(s => s !== slug));
toast({
title: 'Disconnected',
description: `You have disconnected from ${integrationName}.`,
});
}
return (
Configure {integrationName}
{isConnected ? `You are currently connected to ${integrationName}.` : `Enter your credentials to connect to ${integrationName}.`}
{isConnected ? (
Successfully Connected
Your {integrationName} account is now integrated.
) : (
)}
);
}
'use client';
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { ArrowRight } from 'lucide-react';
import Link from 'next/link';
import { kebabCase, startCase } from 'lodash';
import { useLocalStorage } from '@/hooks/use-local-storage';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
const allIntegrationsList = {
payments: [
{ name: 'Stripe', description: 'Accept credit card payments.' },
{ name: 'Square', description: 'POS and payment processing solution.' },
{ name: 'PayPal', description: 'Offer PayPal as a checkout option.' },
{ name: 'Venmo', description: 'Accept payments via Venmo.' },
],
accounting: [
{ name: 'QuickBooks', description: 'Automatically sync sales and taxes.' },
{ name: 'Xero', description: 'Connect your sales data to Xero.' },
{ name: 'Wave', description: 'A popular free accounting alternative.' },
{ name: 'FreshBooks', description: 'Ideal for invoicing and service businesses.' },
],
businessManagement: [
{ name: 'Contractor Plus', description: 'Sync jobs and estimates.' },
{ name: 'M&M POS', description: 'Sync product inventory.' },
{ name: 'Odoo', description: 'Integrate with an all-in-one business suite.' },
{ name: 'Sales Rabbit', description: 'Field sales and lead management tool.' },
{ name: 'HubSpot', description: 'Manage customer relationships with a leading CRM.' },
],
ecommerce: [
{ name: 'Shopify', description: 'Sync inventory with your online store.' },
{ name: 'WooCommerce', description: 'Connect your WordPress e-commerce site.' },
{ name: 'Wix', description: 'Sync with your Wix-based online store.' },
],
shipping: [
{ name: 'UPS', description: 'Create shipping labels and track packages.' },
{ name: 'USPS', description: 'Integrate with United States Postal Service.' },
{ name: 'FedEx', description: 'Manage shipments and get tracking info.' },
{ name: 'Uline', description: 'Order shipping supplies and materials.' },
],
vendors: [
{ name: "Lowe's", description: "Purchase materials and supplies." },
{ name: "The Home Depot", description: "Shop for tools, construction products, and services." },
{ name: "Menards", description: "Home improvement stores located in the Midwestern US." },
{ name: "Harbor Freight", description: "Discount tool and equipment retailer." },
{ name: "Grainger", description: "Industrial supply company for MRO items." },
{ name: "Ferguson", description: "Distributor of plumbing supplies and HVAC products." },
],
communications: [
{ name: 'Ooma Office', description: 'Connect your business phone system.' },
],
marketing: [
{ name: 'Mailchimp', description: 'Add customers to your mailing lists.' },
{ name: 'LoyaltyLion', description: 'Create customer loyalty programs.' },
],
teamManagement: [
{ name: 'Trello', description: 'Create job cards from POS transactions.' },
{ name: 'Slack', description: 'Send sales notifications to your team.' },
],
automation: [
{ name: 'Zapier', description: 'Automate workflows between apps.' },
{ name: 'IFTTT', description: 'Create simple app connections.' },
],
productivity: [
{ name: 'Microsoft', description: 'Integrate with Outlook and Office 365.' },
{ name: 'Google', description: 'Connect with Google Calendar and Drive.' },
],
};
export const StripeIcon = () => (
);
export const IntegrationIcon = ({ name }: { name: string }) => {
switch (kebabCase(name)) {
case 'stripe':
return ;
default:
return ;
}
};
const IntegrationItem = ({ name, description, isConnected }: { name: string; description: string, isConnected: boolean }) => {
const slug = kebabCase(name);
return (
{name}
{description}
);
};
const IntegrationCategory = ({ title, description, items, connectedSlugs }: { title: string, description: string, items: {name: string, description: string}[], connectedSlugs: string[] }) => {
if (items.length === 0) return null;
return (
{title}
{description}
{items.map(item => )}
);
};
export default function IntegrationsPage() {
const [connectedIntegrations] = useLocalStorage('connected-integrations', []);
const filterIntegrations = (connected: boolean) => {
const result: typeof allIntegrationsList = {} as any;
for (const category in allIntegrationsList) {
const key = category as keyof typeof allIntegrationsList;
const filtered = allIntegrationsList[key].filter(item => {
const slug = kebabCase(item.name);
return connected ? connectedIntegrations.includes(slug) : !connectedIntegrations.includes(slug);
});
if (filtered.length > 0) {
result[key] = filtered;
}
}
return result;
};
const available = filterIntegrations(false);
const connected = filterIntegrations(true);
return (
Integrations Hub
Connect your POS to other services to streamline your workflow.
Available Integrations
Connected Integrations ({connectedIntegrations.length})
{Object.entries(available).map(([category, items]) => (
))}
{Object.keys(connected).length > 0 ? Object.entries(connected).map(([category, items]) => (
)) : (
You have no connected integrations yet.
)}
);
}
'use client';
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
export default function MyHrPage() {
return (
My Records
Manage your personal employment details and documents.
My HR Records - Coming Soon!
);
}
'use client';
import React, { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { PlusCircle, PencilFill, TrashFill, CheckCircleFill, XCircleFill } from 'react-bootstrap-icons';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { ProcessorDialog } from '@/components/settings/ProcessorDialog';
import type { PaymentProcessor } from '@/lib/types';
import { useLocalStorage } from '@/hooks/use-local-storage';
const initialProcessors: PaymentProcessor[] = [
{ id: 'proc_1', name: 'Square', status: 'active', type: 'Card Present', posEnabled: true },
{ id: 'proc_2', name: 'Stripe', status: 'inactive', type: 'Card Not Present', posEnabled: false },
{ id: 'proc_3', name: 'PayPal', status: 'active', type: 'Online', posEnabled: true },
];
export default function ManageProcessorsPage() {
const [processors, setProcessors] = useLocalStorage('pos-payment-processors', initialProcessors);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingProcessor, setEditingProcessor] = useState(null);
const handleAddNew = () => {
setEditingProcessor(null);
setIsDialogOpen(true);
};
const handleEdit = (processor: PaymentProcessor) => {
setEditingProcessor(processor);
setIsDialogOpen(true);
};
const handleDelete = (processorId: string) => {
setProcessors(prev => prev.filter(p => p.id !== processorId));
};
const handleSave = (processorData: Omit) => {
if (editingProcessor) {
// Update existing processor
setProcessors(prev => prev.map(p => p.id === editingProcessor.id ? { id: p.id, ...processorData } : p));
} else {
// Add new processor
const newProcessor: PaymentProcessor = {
id: `proc_${Date.now()}`,
...processorData,
};
setProcessors(prev => [...prev, newProcessor]);
}
};
return (
Manage Payment Processors
Add, edit, and manage your payment processor integrations.
Processor Name
Status
Type
POS Enabled
Actions
{processors.map(processor => (
{processor.name}
{processor.status}
{processor.type}
{processor.posEnabled ? (
) : (
)}
Are you sure?
This action cannot be undone. This will permanently delete the processor configuration.
Cancel
handleDelete(processor.id)}>
Delete
))}
{processors.length === 0 && (
No payment processors configured.
)}
);
}
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
export default function TerminalDevicesPage() {
return (
Terminal Devices
Manage and configure your connected payment terminal devices.
Terminal Devices - Coming Soon!
);
}
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
export default function PaymentTypesPage() {
return (
Payment Types
Configure accepted payment methods like cash, card, check, and gift cards.
Payment Types - Coming Soon!
);
}
'use client';
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useLocalStorage } from '@/hooks/use-local-storage';
import { Receipt } from '@/components/pos/Receipt';
import type { Transaction } from '@/lib/types';
import { useToast } from '@/hooks/use-toast';
type PrintBehavior = 'preview' | 'immediate';
interface PrintSettings {
behavior: PrintBehavior;
defaultPrinter: string;
autoPrintCompanyCopy: boolean;
}
interface ReceiptSettings {
showLogo: boolean;
showCustomerPhone: boolean;
showFooterMessage: boolean;
}
const mockPrinters = [
{ id: 'none', name: 'None (Use System Default)' },
{ id: 'terminal-1', name: 'Integrated Terminal Printer (TM-T88VI)' },
{ id: 'kitchen', name: 'Kitchen Network Printer (Epson U220B)' },
{ id: 'office', name: 'Office Laserjet (HP M404n)' },
];
const mockTransaction: Transaction = {
id: 'preview-trans-123',
tab: {
id: 'preview-tab-123',
name: 'Preview Order',
customer: { id: 'cust-001', name: 'John Doe', phone: '555-123-4567', email: 'john@email.com' },
jobNumber: 'JOB-PREV',
cartItems: [
{ id: 'item-1', name: 'Hourly Labor', price: 85.00, quantity: 2, category: 'Services', stock: Infinity, imageUrl: '' },
{ id: 'item-2', name: '1/2" Copper Pipe, 10ft', price: 24.50, quantity: 1, category: 'Plumbing', stock: 1, imageUrl: '' },
],
discount: { type: 'fixed', value: 0 },
},
subtotal: 194.50,
tax: 15.56,
discount: 0,
total: 210.06,
paymentMethod: 'Card',
tendered: 210.06,
change: 0,
timestamp: new Date().toISOString(),
status: 'completed',
};
export default function PrintingSettingsPage() {
const [savedPrintSettings, setSavedPrintSettings] = useLocalStorage('pos-print-settings', {
behavior: 'preview',
defaultPrinter: 'none',
autoPrintCompanyCopy: true,
});
const [savedReceiptSettings, setSavedReceiptSettings] = useLocalStorage('pos-receipt-settings', {
showLogo: true,
showCustomerPhone: true,
showFooterMessage: true,
});
const [localPrintSettings, setLocalPrintSettings] = useState(savedPrintSettings);
const [localReceiptSettings, setLocalReceiptSettings] = useState(savedReceiptSettings);
const { toast } = useToast();
useEffect(() => {
setLocalPrintSettings(savedPrintSettings);
setLocalReceiptSettings(savedReceiptSettings);
}, [savedPrintSettings, savedReceiptSettings]);
const handleSave = () => {
setSavedPrintSettings(localPrintSettings);
setSavedReceiptSettings(localReceiptSettings);
toast({
title: "Settings Saved",
description: "Your printing and receipt preferences have been updated."
});
}
const handleCancel = () => {
setLocalPrintSettings(savedPrintSettings);
setLocalReceiptSettings(savedReceiptSettings);
}
const handlePrintSettingChange = (key: keyof PrintSettings, value: any) => {
setLocalPrintSettings(prev => ({ ...prev, [key]: value }));
};
const handleReceiptSettingChange = (key: keyof ReceiptSettings, value: any) => {
setLocalReceiptSettings(prev => ({ ...prev, [key]: value }));
}
const hasChanges = JSON.stringify(localPrintSettings) !== JSON.stringify(savedPrintSettings) ||
JSON.stringify(localReceiptSettings) !== JSON.stringify(savedReceiptSettings);
return (
Printing Settings
Configure default printing behavior for receipts and invoices.
Choose whether to show a preview or print immediately.
Select the default printer for immediate printing.
Automatically print a second copy of every receipt for your records.
handlePrintSettingChange('autoPrintCompanyCopy', checked)}
/>
Receipt Customization
Configure the information that appears on your receipts.
Display your logo at the top of the receipt.
handleReceiptSettingChange('showLogo', checked)}
/>
Include the customer's phone number.
handleReceiptSettingChange('showCustomerPhone', checked)}
/>
Add a thank you message or return policy.
handleReceiptSettingChange('showFooterMessage', checked)}
/>
{hasChanges && (
)}
Receipt Preview
This is how your receipt will look with the current settings.
);
}
'use client';
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
export default function UserProfilePage() {
return (
User Profile
Manage your personal information and account security.
User Profile Management - Coming Soon!
);
}
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
export default function StoreInfoPage() {
return (
Store Information
Manage your store's name, address, contact information, and hours.
Store Information - Coming Soon!
);
}
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Plus } from 'lucide-react';
export default function TasksPage() {
return (
Tasks
Manage your tasks and to-do items.
Tasks - Coming Soon!
);
}
'use client';
import React, { useState, useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { usePos } from '@/contexts/PosContext';
import { format, formatDistanceStrict, parseISO, startOfWeek, endOfWeek, startOfMonth, endOfMonth, isWithinInterval, subDays } from 'date-fns';
import { ClockFill, CupHotFill, BoxArrowRight, Search, SendFill, FunnelFill, Info, CheckCircleFill, ArrowReturnRight, CalendarFill } from 'react-bootstrap-icons';
import { TimeEditRequestDialog } from '@/components/time-clock/TimeEditRequestDialog';
import type { TimeClockEntry } from '@/lib/types';
import { Checkbox } from '@/components/ui/checkbox';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuCheckboxItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Calendar } from '@/components/ui/calendar';
import type { DateRange } from 'react-day-picker';
type FilterType = 'week' | 'month' | 'custom';
export default function TimeClockPage() {
const {
timeClockEntries,
isClockedIn,
isOnBreak,
shiftDuration,
toggleClock,
startBreak,
endBreak,
} = usePos();
const [searchQuery, setSearchQuery] = useState('');
const [selectedEntries, setSelectedEntries] = useState([]);
const [isRequestDialogOpen, setIsRequestDialogOpen] = useState(false);
const [visibleColumns, setVisibleColumns] = useState({
status: true,
adminNotes: true,
});
const [filterType, setFilterType] = useState('week');
const [dateRange, setDateRange] = useState({
from: startOfWeek(new Date()),
to: endOfWeek(new Date()),
});
const [currentPage, setCurrentPage] = useState(1);
const ITEMS_PER_PAGE = 7; // Number of days to show per page
const handleRequestEdit = () => {
if (selectedEntries.length > 0) {
setIsRequestDialogOpen(true);
}
};
const userEntries = useMemo(() => {
// In a real app with auth, you would filter by user ID.
// For now, we show all entries.
return timeClockEntries
.sort((a, b) => new Date(b.clockInTime).getTime() - new Date(a.clockInTime).getTime());
}, [timeClockEntries]);
const filteredEntries = useMemo(() => {
let entries = userEntries;
if (searchQuery) {
entries = entries.filter(entry =>
format(parseISO(entry.clockInTime), 'MM/dd/yyyy hh:mm a').includes(searchQuery)
);
} else if (dateRange?.from) {
entries = entries.filter(entry =>
isWithinInterval(parseISO(entry.clockInTime), {
start: dateRange.from!,
end: dateRange.to || dateRange.from!,
})
);
}
return entries;
}, [userEntries, searchQuery, dateRange]);
const groupedEntries = useMemo(() => {
return filteredEntries.reduce((acc, entry) => {
const dateKey = format(parseISO(entry.clockInTime), 'yyyy-MM-dd');
if (!acc[dateKey]) {
acc[dateKey] = [];
}
acc[dateKey].push(entry);
return acc;
}, {} as Record);
}, [filteredEntries]);
const sortedGroupKeys = Object.keys(groupedEntries).sort((a, b) => b.localeCompare(a));
const paginatedGroupKeys = useMemo(() => {
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE;
return sortedGroupKeys.slice(startIndex, endIndex);
}, [sortedGroupKeys, currentPage]);
const totalPages = Math.ceil(sortedGroupKeys.length / ITEMS_PER_PAGE);
const handleFilterChange = (type: FilterType) => {
setFilterType(type);
setCurrentPage(1);
setSearchQuery('');
if (type === 'week') {
setDateRange({ from: startOfWeek(new Date()), to: endOfWeek(new Date()) });
} else if (type === 'month') {
setDateRange({ from: startOfMonth(new Date()), to: endOfMonth(new Date()) });
} else {
setDateRange({ from: subDays(new Date(), 7), to: new Date() });
}
};
const currentEntry = userEntries.find(e => !e.clockOutTime);
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedEntries(filteredEntries.filter(e => !e.editRequest || e.editRequest.status !== 'pending'));
} else {
setSelectedEntries([]);
}
};
const handleSelectEntry = (entry: TimeClockEntry, checked: boolean) => {
if (checked) {
setSelectedEntries(prev => [...prev, entry]);
} else {
setSelectedEntries(prev => prev.filter(e => e.id !== entry.id));
}
};
const getStatusIcon = (status?: 'pending' | 'approved' | 'denied') => {
switch(status) {
case 'pending': return ;
case 'approved': return ;
case 'denied': return ;
default: return ;
}
}
const formatDuration = (startTime: string, endTime: string | null): string => {
if (!endTime) return '00:00:00';
const start = parseISO(startTime);
const end = parseISO(endTime);
const diff = end.getTime() - start.getTime();
if (diff < 0) return '00:00:00';
const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
};
const isAllSelected = selectedEntries.length > 0 && selectedEntries.length === filteredEntries.filter(e => !e.editRequest || e.editRequest.status !== 'pending').length;
return (
Your Status
{isClockedIn ? (
<>
Clocked In
{currentEntry?.clockInTime ? `Shift started ${formatDistanceStrict(new Date(), parseISO(currentEntry.clockInTime))} ago` : ''}
>
) : (
<>
Clocked Out
You are not currently on the clock.
>
)}
Shift Duration
{shiftDuration}
{isClockedIn ? (
) : (
)}
{isOnBreak ? (
) : (
)}
Time History
Review recent time entries, select entries to request changes, and view all time entries.
Toggle Columns
{Object.keys(visibleColumns).map((key) => (
setVisibleColumns(prev => ({ ...prev, [key]: checked }))
}
>
{key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())}
))}
{ setSearchQuery(e.target.value); setCurrentPage(1); }}
className="pl-10 max-w-sm"
/>
handleSelectAll(Boolean(checked))}
aria-label="Select all"
/>
Clock In
Clock Out
Total Duration
{visibleColumns.status && (
Status
Approved
Pending
Denied
)}
{visibleColumns.adminNotes && Admin Notes }
{paginatedGroupKeys.length > 0 ? paginatedGroupKeys.map(dateKey => (
{format(parseISO(dateKey), 'EEEE, MMMM dd, yyyy')}
{groupedEntries[dateKey].map(entry => {
const duration = formatDuration(entry.clockInTime, entry.clockOutTime);
const isPending = entry.editRequest?.status === 'pending';
const isChecked = selectedEntries.some(e => e.id === entry.id);
return (
handleSelectEntry(entry, Boolean(checked))}
aria-label={`Select entry ${entry.id}`}
disabled={isPending}
/>
{format(parseISO(entry.clockInTime), 'MM/dd/yy')}
{format(parseISO(entry.clockInTime), 'hh:mm a')}
{entry.clockOutTime ? (
{format(parseISO(entry.clockOutTime), 'MM/dd/yy')}
{format(parseISO(entry.clockOutTime), 'hh:mm a')}
) : Active }
{duration}
{visibleColumns.status && (
{getStatusIcon(entry.editRequest?.status)}
)}
{visibleColumns.adminNotes &&
{entry.editRequest?.admin_notes ? (
View
{entry.editRequest.admin_notes}
) : '-'}
}
{entry.breaks?.map((br, index) => (
Break: {format(parseISO(br.startTime), 'h:mm a')} - {br.endTime ? format(parseISO(br.endTime), 'h:mm a') : 'Ongoing'}
{formatDuration(br.startTime, br.endTime)}
))}
);
})}
)) : (
No time clock entries found for the selected period.
)}
{selectedEntries.length} of {filteredEntries.length} row(s) selected.
Page {currentPage} of {totalPages}
{selectedEntries.length > 0 && (
setSelectedEntries([])}
/>
)}
);
}
'use client';
import React, { useState, useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { usePos } from '@/contexts/PosContext';
import { format, parseISO } from 'date-fns';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Receipt } from '@/components/pos/Receipt';
import type { Transaction } from '@/lib/types';
export default function TransactionsPage() {
const { transactions } = usePos();
const [selectedTransaction, setSelectedTransaction] = useState(null);
const handleViewReceipt = (transaction: Transaction) => {
setSelectedTransaction(transaction);
};
const handleCloseDialog = () => {
setSelectedTransaction(null);
};
const sortedTransactions = useMemo(() => {
return [...transactions].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
}, [transactions]);
return (
Transaction History
Customer
Date
Payment Method
Total
Actions
{sortedTransactions.map(transaction => (
{transaction.tab.customer?.name || 'No Customer'}
{format(parseISO(transaction.timestamp), 'MM/dd/yyyy hh:mm a')}
{transaction.paymentMethod}
${transaction.total.toFixed(2)}
))}
{sortedTransactions.length === 0 && (
No transactions found.
)}
{selectedTransaction && (
)}
);
}
import type {Config} from 'tailwindcss';
export default {
darkMode: ['class'],
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
fontFamily: {
body: ['"Poppins"', 'sans-serif'],
headline: ['"Poppins"', 'sans-serif'],
cursive: ['"Caveat"', 'cursive'],
},
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))',
},
sidebar: {
DEFAULT: 'hsl(var(--sidebar-background))',
foreground: 'hsl(var(--sidebar-foreground))',
primary: 'hsl(var(--sidebar-primary))',
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
accent: 'hsl(var(--sidebar-accent))',
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
border: 'hsl(var(--sidebar-border))',
ring: 'hsl(var(--sidebar-ring))',
},
'ehg-green': '#06fa2e',
'ehg-black': '#000000',
'ehg-main-bg': '#1c1c1c',
'ehg-charcoal': '#222222',
'ehg-dark-grey': '#7d7d7d',
'ehg-white': '#ffffff',
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'accordion-down': {
from: {
height: '0',
},
to: {
height: 'var(--radix-accordion-content-height)',
},
},
'accordion-up': {
from: {
height: 'var(--radix-accordion-content-height)',
},
to: {
height: '0',
},
},
'shake': {
'10%, 90%': { transform: 'translate3d(-1px, 0, 0)' },
'20%, 80%': { transform: 'translate3d(2px, 0, 0)' },
'30%, 50%, 70%': { transform: 'translate3d(-4px, 0, 0)' },
'40%, 60%': { transform: 'translate3d(4px, 0, 0)' },
}
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
'shake': 'shake 0.82s cubic-bezier(.36,.07,.19,.97) both',
},
},
},
plugins: [require('tailwindcss-animate')],
} satisfies Config;
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}