Changeset 057453c


Ignore:
Timestamp:
02/26/25 10:05:32 (5 weeks ago)
Author:
Naum Shapkarovski <naumshapkarovski@…>
Branches:
main
Children:
299af01
Parents:
5d6f37a
Message:

feat: implement employees

Files:
21 added
2 deleted
40 edited

Legend:

Unmodified
Added
Removed
  • package.json

    r5d6f37a r057453c  
    4848    "lodash": "^4.17.21",
    4949    "mui-one-time-password-input": "^2.0.0",
    50     "mvpmasters-shared": "^1.0.9",
    5150    "next": "^13.4.19",
    5251    "notistack": "^3.0.1",
     
    9392    "eslint-plugin-unused-imports": "^3.0.0",
    9493    "prettier": "^3.0.3",
    95     "prisma": "^6.3.1"
     94    "prisma": "^6.3.1",
     95    "typescript": "5.7.3"
    9696  },
    9797  "prisma": {
  • prisma/schema.prisma

    r5d6f37a r057453c  
    88}
    99
    10 model Customer {
     10model Client {
    1111  id              String        @id @default(uuid())
    1212  companyId       String?       // Optional company identifier
     
    2121  status          CustomerStatus @default(active)
    2222
    23   bankAccounts    BankAccount[] // One-to-many relation
    24   invoicesSent    Invoice[] @relation("InvoiceFrom")
    2523  invoicesReceived Invoice[] @relation("InvoiceTo")
    26 }
    27 
    28 model BankAccount {
    29   id             String   @id @default(uuid())
    30   customerId     String
    31   customer       Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
    32   accountNumber  String?
    33   bicSwift       String?
    34   iban           String?
    35   routingNumber  String?
    36   currency       Currency
    3724}
    3825
     
    4027  id          String  @id @default(uuid())
    4128  name        String
    42   sprintPrice Float
    43   hourPrice   Float
    44   monthPrice  Float
     29  sprint      Float
     30  hour        Float
     31  month       Float
     32  tenantId    String
     33  tenant      Tenant   @relation(fields: [tenantId], references: [id], onDelete: Cascade)
    4534
    46   invoiceItems InvoiceItem[]
     35  lineItems LineItem[]
    4736}
    4837
     
    6453
    6554  invoiceFromId String
    66   invoiceFrom   Customer       @relation("InvoiceFrom", fields: [invoiceFromId], references: [id], onDelete: Cascade)
     55  invoiceFrom   Tenant        @relation(fields: [invoiceFromId], references: [id], onDelete: Cascade)
    6756
    6857  invoiceToId   String
    69   invoiceTo     Customer       @relation("InvoiceTo", fields: [invoiceToId], references: [id], onDelete: Cascade)
     58  invoiceTo     Client       @relation("InvoiceTo", fields: [invoiceToId], references: [id], onDelete: Cascade)
    7059
    71   items         InvoiceItem[]
     60  items         LineItem[]
    7261}
    7362
    74 model InvoiceItem {
     63model LineItem {
    7564  id          String   @id @default(uuid())
    7665  title       String
     
    8574}
    8675
     76model Tenant {
     77  id                String    @id @default(cuid())
     78  name              String
     79  email             String    @unique
     80  address           Json      // Holds {street: string, city?: string, country: string, state?: string, zip: string}
     81  bankAccounts      Json?     // Holds {eur?: {accountNumber?, bicSwift?, iban?, routingNumber?}, usd?: {...}}
     82  logoUrl           String?
     83  phoneNumber       String?
     84  vatNumber         String?
     85  companyNumber     String?
     86  representative    String
     87  lastInvoiceNumber String       @default("0")
     88  createdAt         DateTime  @default(now())
     89  updatedAt         DateTime  @updatedAt
     90  invoicesSent      Invoice[]
     91  services          Service[]
     92  employees         Employee[]
     93}
     94
     95model Employee {
     96  id          String         @id @default(uuid())
     97  name        String
     98  email       String         @unique
     99  status      EmployeeStatus @default(active)
     100  iban        String?
     101  cv          String?
     102  photo       String?
     103  project     String?
     104  tenantId    String
     105  tenant      Tenant         @relation(fields: [tenantId], references: [id], onDelete: Cascade)
     106}
     107
    87108// Enums
    88109enum CustomerStatus {
     
    93114
    94115enum InvoiceStatus {
    95   DRAFT
    96   PROCESSING
    97   PENDING
    98   OVERDUE
    99   PAID
     116  draft
     117  processing
     118  pending
     119  overdue
     120  paid
    100121}
    101122
     
    106127
    107128enum QuantityType {
    108   UNIT
    109   HOUR
    110   SPRINT
    111   MONTH
     129  Unit
     130  Hour
     131  Sprint
     132  Month
    112133}
    113134
    114135enum Month {
    115   JANUARY
    116   FEBRUARY
    117   MARCH
    118   APRIL
    119   MAY
    120   JUNE
    121   JULY
    122   AUGUST
    123   SEPTEMBER
    124   OCTOBER
    125   NOVEMBER
    126   DECEMBER
     136  January
     137  February
     138  March
     139  April
     140  May
     141  June
     142  July
     143  August
     144  September
     145  October
     146  November
     147  December
    127148}
     149
     150enum EmployeeStatus {
     151  active
     152  inactive
     153}
  • prisma/seed.js

    r5d6f37a r057453c  
    44
    55async function main() {
    6   console.log('🌱 Seeding database...');
     6  // Clear existing data
     7  await prisma.service.deleteMany();
     8  await prisma.tenant.deleteMany();
    79
    8   // Create Customers
    9   const customer1 = await prisma.customer.create({
    10     data: {
    11       name: 'Acme Corp',
    12       email: 'contact@acme.com',
    13       street: '123 Main St',
    14       city: 'New York',
    15       country: 'USA',
    16       state: 'NY',
    17       zip: '10001',
    18       phoneNumber: '+1 555-555-5555',
    19       vatNumber: 'US123456789',
    20       companyNumber: '123456789',
    21       representative: 'John Doe',
    22       status: 'ACTIVE',
    23       logoUrl: 'https://example.com/logo.png',
     10  // Define default tenant data
     11  const tenantData = {
     12    name: "Default Company",
     13    email: "contact@defaultcompany.com",
     14    address: {
     15      street: "123 Business Street",
     16      city: "Business City",
     17      state: "BS",
     18      zip: "12345",
     19      country: "United States"
    2420    },
     21    phoneNumber: "+1 234 567 8900",
     22    representative: "John Doe",
     23    lastInvoiceNumber: "1",
     24    logoUrl: "https://example.com/default-logo.png",
     25    vatNumber: "VAT123456789",
     26    companyNumber: "COMP123456",
     27    bankAccounts: {
     28      eur: {
     29        accountNumber: "1234567890",
     30        routingNumber: "987654321",
     31        bicSwift: "DEFBANKXXX",
     32        iban: "DE89370400440532013000"
     33      },
     34      usd: {
     35        accountNumber: "0987654321",
     36        routingNumber: "123456789",
     37        bicSwift: "DEFBANKXXX",
     38        iban: "US89370400440532013000"
     39      }
     40    },
     41    // Add services along with the tenant creation
     42    services: {
     43      create: [
     44        {
     45          name: "Web Development",
     46          sprint: 5000,
     47          hour: 150,
     48          month: 8000
     49        },
     50        {
     51          name: "UI/UX Design",
     52          sprint: 3000,
     53          hour: 120,
     54          month: 6000
     55        },
     56        {
     57          name: "Consulting",
     58          sprint: 4000,
     59          hour: 200,
     60          month: 7000
     61        }
     62      ]
     63    }
     64  };
     65
     66  // Create default tenant with services
     67  const defaultTenant = await prisma.tenant.create({
     68    data: tenantData,
     69    include: {
     70      services: true
     71    }
    2572  });
    2673
    27   const customer2 = await prisma.customer.create({
    28     data: {
    29       name: 'Globex Ltd.',
    30       email: 'info@globex.com',
    31       street: '456 Industrial Rd',
    32       city: 'Los Angeles',
    33       country: 'USA',
    34       state: 'CA',
    35       zip: '90001',
    36       phoneNumber: '+1 555-123-4567',
    37       vatNumber: 'US987654321',
    38       companyNumber: '987654321',
    39       representative: 'Jane Smith',
    40       status: 'INACTIVE',
    41       logoUrl: 'https://example.com/logo2.png',
    42     },
    43   });
     74  console.log('Seeded default tenant:', defaultTenant);
    4475
    45   // Create Bank Accounts
    46   await prisma.bankAccount.createMany({
    47     data: [
    48       {
    49         customerId: customer1.id,
    50         accountNumber: '1234567890',
    51         bicSwift: 'ACMEUS33',
    52         iban: 'US12345678901234567890',
    53         routingNumber: '111000025',
    54         currency: 'USD',
    55       },
    56       {
    57         customerId: customer2.id,
    58         accountNumber: '0987654321',
    59         bicSwift: 'GLOBEXUS12',
    60         iban: 'US09876543210987654321',
    61         routingNumber: '222000033',
    62         currency: 'EUR',
    63       },
    64     ],
    65   });
    66 
    67   // Create Services
    68   const service1 = await prisma.service.create({
    69     data: {
    70       name: 'Web Development',
    71       sprintPrice: 5000.0,
    72       hourPrice: 100.0,
    73       monthPrice: 20000.0,
    74     },
    75   });
    76 
    77   const service2 = await prisma.service.create({
    78     data: {
    79       name: 'SEO Optimization',
    80       sprintPrice: 3000.0,
    81       hourPrice: 75.0,
    82       monthPrice: 12000.0,
    83     },
    84   });
    85 
    86   // Create Invoices
    87   const invoice1 = await prisma.invoice.create({
    88     data: {
    89       dueDate: new Date('2025-03-15'),
    90       status: 'PENDING',
    91       currency: 'USD',
    92       quantityType: 'HOUR',
    93       subTotal: 5000.0,
    94       createDate: new Date(),
    95       month: 'FEBRUARY',
    96       discount: 0.0,
    97       taxes: 500.0,
    98       totalAmount: 5500.0,
    99       invoiceNumber: 'INV-2025-001',
    100       pdfRef: 'https://example.com/invoice1.pdf',
    101       invoiceFromId: customer1.id,
    102       invoiceToId: customer2.id,
    103     },
    104   });
    105 
    106   // Create Invoice Items
    107   await prisma.invoiceItem.create({
    108     data: {
    109       title: 'Web Development - Sprint 1',
    110       price: 5000.0,
    111       total: 5000.0,
    112       quantity: 1,
    113       description: 'Development of the MVP frontend',
    114       serviceId: service1.id,
    115       invoiceId: invoice1.id,
    116     },
    117   });
     76  console.log('🌱 Seeding database...');
    11877
    11978  console.log('✅ Seeding complete!');
     
    12281main()
    12382  .catch((e) => {
    124     console.error(e);
     83    console.error('Error seeding database:', e);
    12584    process.exit(1);
    12685  })
  • src/api/customer.ts

    r5d6f37a r057453c  
    11import { useMemo } from 'react';
    22// types
    3 import { Customer } from 'mvpmasters-shared';
     3import { Customer } from 'src/schemas';
    44// db
    55import { endpoints, fetcher } from 'src/utils/axios';
  • src/api/invoice.ts

    r5d6f37a r057453c  
    11import { useMemo } from 'react';
    22// types
    3 import { Invoice } from 'mvpmasters-shared';
     3import { Invoice, UpdateInvoice } from 'src/schemas';
    44// db
    55import useSWR from 'swr';
    66import { endpoints, fetcher } from 'src/utils/axios';
     7import axios from 'src/utils/axios';
     8import { mutate } from 'swr';
     9import { useSWRConfig } from 'swr';
    710
    811interface InvoiceFilters {
     
    5154}
    5255
    53 // export function useGetInvoice({ id }: { id: string }) {
    54 //   const collectionName = collections.invoice;
     56export function useGetInvoice({ id }: { id: string }) {
     57  const path = endpoints.invoice;
    5558
    56 //   const { data, isLoading, error, isValidating } = useSWR(
    57 //     [collectionName, id],
    58 //     () => documentFetcher<Invoice>(collectionName, id),
    59 //     {
    60 //       revalidateOnFocus: false,
    61 //     }
    62 //   );
     59  const { data, isLoading, error, isValidating } = useSWR(
     60    `${path}/${id}`,
     61    () => fetcher<Invoice>(`${path}/${id}`),
     62    {
     63      revalidateOnFocus: false,
     64    }
     65  );
    6366
    64 //   const memoizedValue = useMemo(
    65 //     () => ({
    66 //       currentInvoice: data || null,
    67 //       currentInvoiceLoading: isLoading,
    68 //       currentInvoiceError: error,
    69 //       currentInvoiceValidating: isValidating,
    70 //       currentInvoiceEmpty: !isLoading && !data,
    71 //     }),
    72 //     [data, error, isLoading, isValidating]
    73 //   );
     67  const memoizedValue = useMemo(
     68    () => ({
     69      currentInvoice: data || null,
     70      currentInvoiceLoading: isLoading,
     71      currentInvoiceError: error,
     72      currentInvoiceValidating: isValidating,
     73      currentInvoiceEmpty: !isLoading && !data,
     74    }),
     75    [data, error, isLoading, isValidating]
     76  );
    7477
    75 //   return memoizedValue;
    76 // }
     78  return memoizedValue;
     79}
     80
     81// Add this interface for the create invoice payload
     82interface CreateInvoicePayload {
     83  createDate: Date;
     84  dueDate: Date;
     85  items: any[];
     86  invoiceNumber: string;
     87  invoiceFrom: any;
     88  invoiceTo: any;
     89  currency: string;
     90  quantityType: string;
     91  month: string;
     92  status?: string;
     93  taxes?: number;
     94  discount?: number;
     95  totalAmount: number;
     96  pdfRef?: string;
     97}
     98
     99export async function createInvoice(data: CreateInvoicePayload): Promise<Invoice> {
     100  const response = await axios.post<Invoice>(endpoints.invoice, data);
     101
     102  // Mutate the SWR cache to include the new invoice
     103  await mutate(
     104    endpoints.invoice,
     105    (existingInvoices: Invoice[] = []) => [response.data, ...existingInvoices],
     106    false // Set to false to avoid revalidation since we already have the new data
     107  );
     108
     109  return response.data;
     110}
     111
     112export async function updateInvoice(id: string, data: Partial<UpdateInvoice>) {
     113  const response = await axios.patch<Invoice>(`${endpoints.invoice}/${id}`, data);
     114
     115  // Mutate the individual invoice cache
     116  await mutate(`${endpoints.invoice}/${id}`, response.data, false);
     117
     118  // Mutate the invoice list cache
     119  await mutate(
     120    endpoints.invoice,
     121    (existingInvoices: Invoice[] = []) =>
     122      existingInvoices.map((invoice) => (invoice.id === id ? response.data : invoice)),
     123    false
     124  );
     125
     126  return response.data;
     127}
     128
     129export async function deleteInvoice(id: string) {
     130  const response = await axios.delete<Invoice>(`${endpoints.invoice}/${id}`);
     131
     132  // Mutate the invoice list cache to remove the deleted invoice
     133  await mutate(
     134    endpoints.invoice,
     135    (existingInvoices: Invoice[] = []) => existingInvoices.filter((invoice) => invoice.id !== id),
     136    false
     137  );
     138
     139  return response.data;
     140}
     141
     142// Update the useDeleteInvoice hook to use the new implementation
     143export function useDeleteInvoice() {
     144  const deleteInvoiceMutation = async (id: string) => {
     145    try {
     146      await deleteInvoice(id);
     147      return true;
     148    } catch (error) {
     149      console.error('Error deleting invoice:', error);
     150      throw error;
     151    }
     152  };
     153
     154  return { deleteInvoiceMutation };
     155}
  • src/api/service.ts

    r5d6f37a r057453c  
    11import { useMemo } from 'react';
    22// types
    3 import { Service } from 'mvpmasters-shared';
    4 // db
    5 import { collections, collectionFetcher as fetcher } from 'src/lib/firestore';
     3import { Service } from 'src/schemas';
    64// swr
    75import useSWR from 'swr';
     6import { endpoints, fetcher } from 'src/utils/axios';
    87
    98export function useGetServices() {
    10   const collectionName = collections.service;
    11 
    12   const { data, isLoading, error, isValidating } = useSWR(collectionName, fetcher<Service>, {
    13     revalidateOnFocus: false,
    14   });
     9  const { data, isLoading, error, isValidating } = useSWR<Service[]>(
     10    endpoints.service,
     11    () => fetcher<Service[]>(endpoints.service),
     12    {
     13      revalidateOnFocus: false,
     14    }
     15  );
    1516
    1617  const memoizedValue = useMemo(
  • src/app/api/customers/[id]/route.ts

    r5d6f37a r057453c  
    11import { NextRequest, NextResponse } from 'next/server';
    2 import { customerSchema } from 'mvpmasters-shared';
     2import { customerSchema } from 'src/schemas';
    33import prisma from 'src/lib/prisma';
    44import { authenticateRequest } from 'src/lib/auth-middleware';
     
    1414    const validatedData = customerSchema.partial().parse(body);
    1515
    16     const customer = await prisma.customer.update({
    17       where: { id: params.id, userId },
    18       data: validatedData,
     16    const customer = await prisma.client.update({
     17      where: { id: params.id },
     18      data: {
     19        ...validatedData,
     20        bankAccounts: undefined,
     21      },
    1922    });
    2023
  • src/app/api/customers/route.ts

    r5d6f37a r057453c  
    11import { NextRequest, NextResponse } from 'next/server';
    2 import { customerTableFiltersSchema, newCustomerSchema } from 'mvpmasters-shared';
     2import { customerTableFiltersSchema, newCustomerSchema } from 'src/schemas';
    33import prisma from 'src/lib/prisma';
    44import { authenticateRequest } from 'src/lib/auth-middleware';
     5import { CustomerStatus } from '@prisma/client';
    56
    67export async function GET(request: NextRequest) {
     
    2324    const validatedFilters = customerTableFiltersSchema.parse(filters);
    2425
    25     const customers = await prisma.customer.findMany({
     26    const customers = await prisma.client.findMany({
    2627      where: {
    2728        name: { contains: validatedFilters.name, mode: 'insensitive' },
    28         status: validatedFilters.status ? { equals: validatedFilters.status } : undefined,
     29        status: validatedFilters.status
     30          ? { equals: validatedFilters.status as CustomerStatus }
     31          : undefined,
    2932      },
    3033    });
     
    5053    console.log('validatedData', validatedData);
    5154
    52     const customer = await prisma.customer.create({
     55    const customer = await prisma.client.create({
    5356      data: {
    5457        ...validatedData,
  • src/app/api/invoices/[id]/route.ts

    r5d6f37a r057453c  
    11import { NextRequest, NextResponse } from 'next/server';
    2 import { invoiceSchema } from 'mvpmasters-shared';
     2import { invoiceSchema, updateInvoiceSchema } from 'src/schemas';
    33import prisma from 'src/lib/prisma';
    44import { authenticateRequest } from 'src/lib/auth-middleware';
     5import { Prisma } from '@prisma/client';
    56
    6 export async function PATCH(request: NextRequest, { params }: { params: { id: string } }) {
     7export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
    78  try {
    8     // Authenticate the request
     9    // Validate ID format
     10    if (!params.id || !/^[0-9a-fA-F-]+$/.test(params.id)) {
     11      return NextResponse.json({ error: 'Invalid invoice ID format' }, { status: 400 });
     12    }
     13
     14    // Authenticate request
    915    const authResult = await authenticateRequest(request);
    1016    if (authResult instanceof NextResponse) {
     
    1319    const { userId } = authResult;
    1420
    15     const body = await request.json();
    16     const validatedData = invoiceSchema.partial().parse(body);
    17 
    18     const invoice = await prisma.invoice.update({
    19       where: { id: params.id, userId },
    20       data: {
    21         ...validatedData,
    22         items: validatedData.items
    23           ? {
    24               deleteMany: {},
    25               create: validatedData.items,
    26             }
    27           : undefined,
     21    // Fetch invoice with user check
     22    const invoice = await prisma.invoice.findFirst({
     23      where: {
     24        id: params.id,
     25        // invoiceFromId: userId,
    2826      },
    2927      include: {
    30         items: {
    31           include: {
    32             service: true,
    33           },
    34         },
    3528        invoiceFrom: true,
    3629        invoiceTo: true,
     30        items: true,
    3731      },
    3832    });
    3933
     34    if (!invoice) {
     35      return NextResponse.json({ error: 'Invoice not found or access denied' }, { status: 404 });
     36    }
     37
    4038    return NextResponse.json(invoice);
    4139  } catch (error) {
    42     return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
     40    console.error('Error fetching invoice:', error);
     41
     42    if (error instanceof Prisma.PrismaClientKnownRequestError) {
     43      return NextResponse.json({ error: 'Database error occurred' }, { status: 500 });
     44    }
     45
     46    return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
    4347  }
    4448}
     49
     50export async function PATCH(request: NextRequest, { params }: { params: { id: string } }) {
     51  try {
     52    // Validate ID format
     53    if (!params.id || !/^[0-9a-fA-F-]+$/.test(params.id)) {
     54      return NextResponse.json({ error: 'Invalid invoice ID format' }, { status: 400 });
     55    }
     56
     57    // Authenticate request
     58    const authResult = await authenticateRequest(request);
     59    if (authResult instanceof NextResponse) {
     60      return authResult;
     61    }
     62    const { userId } = authResult;
     63
     64    // Parse and validate request body
     65    const body = await request.json();
     66
     67    const validation = updateInvoiceSchema.partial().safeParse(body);
     68
     69    if (!validation.success) {
     70      return NextResponse.json(
     71        { error: 'Invalid invoice data', details: validation.error.format() },
     72        { status: 400 }
     73      );
     74    }
     75
     76    // Verify invoice exists and belongs to user
     77    const existingInvoice = await prisma.invoice.findFirst({
     78      where: {
     79        id: params.id,
     80        // invoiceFromId: userId,
     81      },
     82    });
     83
     84    if (!existingInvoice) {
     85      return NextResponse.json({ error: 'Invoice not found or access denied' }, { status: 404 });
     86    }
     87
     88    // Update invoice and related data
     89    const updatedInvoice = await prisma.$transaction(async (tx) => {
     90      // Conditionally delete and recreate items only if they are provided
     91      if (validation.data.items) {
     92        await tx.invoiceItem.deleteMany({
     93          where: { invoiceId: params.id },
     94        });
     95      }
     96
     97      // Update the invoice and create new items if provided
     98      return tx.invoice.update({
     99        where: { id: params.id },
     100        data: {
     101          invoiceNumber: validation.data.invoiceNumber,
     102          createDate: validation.data.createDate,
     103          dueDate: validation.data.dueDate,
     104          status: validation.data.status,
     105          currency: validation.data.currency,
     106          quantityType: validation.data.quantityType,
     107          subTotal: validation.data.subTotal,
     108          month: validation.data.month,
     109          totalAmount: validation.data.totalAmount,
     110          discount: validation.data.discount,
     111          taxes: validation.data.taxes,
     112          pdfRef: validation.data.pdfRef,
     113          invoiceTo: {
     114            update: validation.data.invoiceTo,
     115          },
     116          items: validation.data.items
     117            ? {
     118                create: validation.data.items.map((item) => ({
     119                  ...item,
     120                  service: {
     121                    connect: { id: item.service.id },
     122                  },
     123                })),
     124              }
     125            : undefined,
     126        },
     127        include: {
     128          invoiceFrom: true,
     129          invoiceTo: true,
     130          items: true,
     131        },
     132      });
     133    });
     134
     135    return NextResponse.json(updatedInvoice);
     136  } catch (error) {
     137    console.error('Error updating invoice:', error);
     138
     139    if (error instanceof Prisma.PrismaClientKnownRequestError) {
     140      return NextResponse.json({ error: 'Database error occurred' }, { status: 500 });
     141    }
     142
     143    return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
     144  }
     145}
     146
     147export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) {
     148  try {
     149    // Validate ID format
     150    if (!params.id || !/^[0-9a-fA-F-]+$/.test(params.id)) {
     151      return NextResponse.json({ error: 'Invalid invoice ID format' }, { status: 400 });
     152    }
     153
     154    // Authenticate request
     155    const authResult = await authenticateRequest(request);
     156    if (authResult instanceof NextResponse) {
     157      return authResult;
     158    }
     159    const { userId } = authResult;
     160    console.log('userId', userId);
     161
     162    // Verify invoice exists and belongs to user
     163    const existingInvoice = await prisma.invoice.findFirst({
     164      where: {
     165        id: params.id,
     166      },
     167    });
     168
     169    if (!existingInvoice) {
     170      return NextResponse.json({ error: 'Invoice not found or access denied' }, { status: 404 });
     171    }
     172
     173    // Delete invoice and related items in a transaction
     174    await prisma.$transaction(async (tx) => {
     175      // Delete related items first
     176      await tx.invoiceItem.deleteMany({
     177        where: { invoiceId: params.id },
     178      });
     179
     180      // Delete the invoice
     181      await tx.invoice.delete({
     182        where: { id: params.id },
     183      });
     184    });
     185
     186    return NextResponse.json({ message: 'Invoice deleted successfully' }, { status: 200 });
     187  } catch (error) {
     188    console.error('Error deleting invoice:', error);
     189
     190    if (error instanceof Prisma.PrismaClientKnownRequestError) {
     191      if (error.code === 'P2025') {
     192        return NextResponse.json({ error: 'Invoice not found' }, { status: 404 });
     193      }
     194      return NextResponse.json({ error: 'Database error occurred' }, { status: 500 });
     195    }
     196
     197    return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
     198  }
     199}
  • src/app/api/invoices/route.ts

    r5d6f37a r057453c  
    11import { NextRequest, NextResponse } from 'next/server';
    2 import { invoiceSchema, invoiceTableFiltersSchema } from 'mvpmasters-shared';
     2import { createInvoiceSchema, invoiceSchema, invoiceTableFiltersSchema } from 'src/schemas';
    33import prisma from 'src/lib/prisma';
    44import { auth } from 'src/lib/firebase-admin';
     5import { InvoiceStatus } from '@prisma/client';
    56
    67// Helper function to get userId from Authorization header
     
    4142    const invoices = await prisma.invoice.findMany({
    4243      where: {
    43         userId,
    44         status: validatedFilters.status ? { equals: validatedFilters.status } : undefined,
     44        status: validatedFilters.status
     45          ? { equals: validatedFilters.status as InvoiceStatus }
     46          : undefined,
    4547        createDate: {
    46           gte: validatedFilters.startDate,
    47           lte: validatedFilters.endDate,
     48          ...(validatedFilters.startDate && { gte: validatedFilters.startDate }),
     49          ...(validatedFilters.endDate && { lte: validatedFilters.endDate }),
    4850        },
    4951        items:
     
    7779
    7880    const body = await request.json();
    79     const validatedData = invoiceSchema.parse(body);
     81    const validatedData = createInvoiceSchema.parse(body);
     82
     83    const tenant = await prisma.tenant.findUnique({
     84      where: { id: validatedData.invoiceFrom.id },
     85    });
     86
     87    const toCustomer = await prisma.client.findUnique({
     88      where: { id: validatedData.invoiceTo.id },
     89    });
     90
     91    if (!tenant || !toCustomer) {
     92      return NextResponse.json({ error: 'Invoice sender or recipient not found' }, { status: 404 });
     93    }
     94
     95    // Update lastInvoiceNumber in tenant
     96    const updatedTenant = await prisma.tenant.update({
     97      where: { id: tenant.id },
     98      data: {
     99        lastInvoiceNumber: validatedData.invoiceNumber,
     100      },
     101    });
    80102
    81103    const invoice = await prisma.invoice.create({
    82104      data: {
    83         ...validatedData,
    84         userId,
     105        dueDate: validatedData.dueDate,
     106        status: validatedData.status,
     107        currency: validatedData.currency,
     108        quantityType: validatedData.quantityType,
     109        subTotal: validatedData.subTotal,
     110        createDate: validatedData.createDate,
     111        month: validatedData.month,
     112        discount: validatedData.discount,
     113        taxes: validatedData.taxes,
     114        totalAmount: validatedData.totalAmount,
     115        invoiceNumber: validatedData.invoiceNumber,
     116        invoiceFromId: tenant.id,
     117        invoiceToId: toCustomer.id,
    85118        items: {
    86           create: validatedData.items,
     119          create: validatedData.items.map((item) => ({
     120            title: item.title,
     121            price: item.price,
     122            total: item.total,
     123            quantity: item.quantity,
     124            description: item.description,
     125            service: {
     126              connect: { id: item.service.id },
     127            },
     128          })),
    87129        },
    88130      },
     
    100142    return NextResponse.json(invoice, { status: 201 });
    101143  } catch (error) {
     144    console.error(error);
    102145    return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
    103146  }
  • src/layouts/dashboard/config-navigation.tsx

    r5d6f37a r057453c  
    5353        items: [
    5454          {
    55             title: 'banking',
     55            title: 'dashboard',
    5656            path: paths.dashboard.banking,
    5757            icon: ICONS.banking,
     
    8585            ],
    8686          },
     87          {
     88            title: 'Employees',
     89            path: paths.dashboard.employee.list,
     90            icon: <SvgColor src="/assets/icons/navbar/ic_user.svg" />,
     91            children: [
     92              { title: 'list', path: paths.dashboard.employee.list },
     93              { title: 'create', path: paths.dashboard.employee.new },
     94            ],
     95          },
    8796        ],
    8897      },
  • src/lib/auth-middleware.ts

    r5d6f37a r057453c  
    44export interface AuthenticatedRequest extends NextRequest {
    55  userId: string;
     6  tenantId: string;
    67}
    78
    89export async function authenticateRequest(
    910  request: NextRequest
    10 ): Promise<{ userId: string } | NextResponse> {
     11): Promise<{ userId: string; tenantId: string } | NextResponse> {
    1112  // Get the authorization header
    1213  const authHeader = request.headers.get('Authorization');
     
    2223    const decodedToken = await auth.verifyIdToken(token);
    2324    const userId = decodedToken.uid;
     25    const tenantId = decodedToken.customClaims?.tenantId || 'cm7bwtjy80000pb0m5qenk8am';
    2426
    25     if (!userId) {
     27    if (!userId || !tenantId) {
    2628      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    2729    }
    2830
    29     return { userId };
     31    return { userId, tenantId };
    3032  } catch (error) {
    3133    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  • src/routes/paths.ts

    r5d6f37a r057453c  
    3434      list: `${ROOTS.DASHBOARD}/customer/list`,
    3535    },
     36    employee: {
     37      root: '/dashboard/employee',
     38      list: '/dashboard/employee/list',
     39      new: '/dashboard/employee/new',
     40      edit: (id: string) => `/dashboard/employee/${id}/edit`,
     41    },
    3642  },
    3743};
  • src/sections/company/company-item.tsx

    r5d6f37a r057453c  
    44import Stack, { StackProps } from '@mui/material/Stack';
    55// types
    6 import { Customer } from 'mvpmasters-shared';
     6import { Customer } from 'src/schemas';
    77// components
    88import Label from 'src/components/label';
  • src/sections/company/company-list-dialog.tsx

    r5d6f37a r057453c  
    99import ListItemButton, { listItemButtonClasses } from '@mui/material/ListItemButton';
    1010// types
    11 import { Customer } from 'mvpmasters-shared';
     11import { Customer } from 'src/schemas';
    1212// components
    1313import Iconify from 'src/components/iconify';
    1414import SearchNotFound from 'src/components/search-not-found';
    1515import { createFullAddress } from 'src/utils/create-full-address';
     16import { Tenant } from 'src/schemas';
    1617
    1718// ----------------------------------------------------------------------
     
    1920type Props = {
    2021  title?: string;
    21   list: Customer[];
     22  tenant?: Tenant;
     23  list?: Customer[];
    2224  action?: React.ReactNode;
    2325  //
     
    2628  //
    2729  selected: (selectedId: string) => boolean;
    28   onSelect: (address: Customer | null) => void;
     30  onSelect: (address: Customer | Tenant | null) => void;
    2931};
    3032
    3133export default function AddressListDialog({
    3234  title = 'Address Book',
     35  tenant,
    3336  list,
    3437  action,
     
    4346
    4447  const dataFiltered = applyFilter({
    45     inputData: list,
     48    inputData: list || (tenant ? [tenant] : []),
    4649    query: searchCompany,
    4750  });
     
    5457
    5558  const handleSelectCompany = useCallback(
    56     (company: Customer | null) => {
     59    (company: Customer | Tenant | null) => {
    5760      onSelect(company);
    5861      setSearchCompany('');
     
    156159// ----------------------------------------------------------------------
    157160
    158 function applyFilter({ inputData, query }: { inputData: Customer[]; query: string }) {
     161function applyFilter({ inputData, query }: { inputData: Tenant[] | Customer[]; query: string }) {
    159162  if (query) {
    160163    return inputData.filter(
  • src/sections/invoice/invoice-details.tsx

    r5d6f37a r057453c  
    1818import { fCurrency } from 'src/utils/format-number';
    1919// types
    20 import { Invoice } from 'mvpmasters-shared';
     20import { Invoice } from 'src/schemas';
    2121// components
    2222import Label from 'src/components/label';
     
    2828import { getQuantityType } from 'src/utils/get-invoice-quantity-type';
    2929import InvoiceToolbar from './invoice-toolbar';
     30import { updateInvoice } from 'src/api/invoice';
    3031
    3132// ----------------------------------------------------------------------
     
    5960  const handleChangeStatus = useCallback(
    6061    async (event: React.ChangeEvent<HTMLInputElement>) => {
    61       await updateDocument(collections.invoice, invoice.id, { status: event.target.value });
     62      // await updateDocument(collections.invoice, invoice.id, { status: event.target.value });
     63      await updateInvoice(invoice.id, {
     64        status: event.target.value as 'draft' | 'processing' | 'pending' | 'overdue' | 'paid',
     65      });
    6266      mutate([collections.invoice, invoice.id]);
    6367    },
     
    240244            {invoice.invoiceTo.name}
    241245            <br />
    242             {!!invoice.invoiceTo.companyId && (
     246            {!!invoice.invoiceTo.companyNumber && (
    243247              <>
    244                 Company ID: {invoice.invoiceTo.companyId}
     248                Company ID: {invoice.invoiceTo.companyNumber}
    245249                <br />
    246250              </>
     
    257261              Date Issued
    258262            </Typography>
    259             {fDate(invoice.createDate.toDate())}
     263            {fDate(invoice.createDate)}
    260264          </Stack>
    261265
     
    264268              Due Date
    265269            </Typography>
    266             {fDate(invoice.dueDate.toDate())}
     270            {fDate(invoice.dueDate)}
    267271          </Stack>
    268272        </Box>
  • src/sections/invoice/invoice-ee-pdf.tsx

    r5d6f37a r057453c  
    55import { fCurrency } from 'src/utils/format-number';
    66// types
    7 import { Invoice } from 'mvpmasters-shared';
     7import { Invoice } from 'src/schemas';
    88import { createFullAddress } from 'src/utils/create-full-address';
    99import { getQuantityType } from 'src/utils/get-invoice-quantity-type';
     
    229229          <View style={styles.col6}>
    230230            <Text style={[styles.subtitle1, styles.mb4]}>Date Issued</Text>
    231             <Text style={styles.body2}>{fDate(createDate.toDate())}</Text>
     231            <Text style={styles.body2}>{fDate(createDate)}</Text>
    232232          </View>
    233233          <View style={styles.col6}>
    234234            <Text style={[styles.subtitle1, styles.mb4]}>Due date</Text>
    235             <Text style={styles.body2}>{fDate(dueDate.toDate())}</Text>
     235            <Text style={styles.body2}>{fDate(dueDate)}</Text>
    236236          </View>
    237237        </View>
  • src/sections/invoice/invoice-mk-pdf.tsx

    r5d6f37a r057453c  
    55import { fCurrency } from 'src/utils/format-number';
    66// types
    7 import { Invoice } from 'mvpmasters-shared';
     7import { Invoice } from 'src/schemas';
    88import { createFullAddress } from 'src/utils/create-full-address';
    99import { getQuantityType } from 'src/utils/get-invoice-quantity-type';
     
    179179          <View style={styles.col6}>
    180180            <Text style={[styles.subtitle1, styles.mb4]}>Date Issued</Text>
    181             <Text style={styles.body2}>{fDate(createDate.toDate())}</Text>
     181            <Text style={styles.body2}>{fDate(createDate)}</Text>
    182182          </View>
    183183          <View style={styles.col6}>
    184184            <Text style={[styles.subtitle1, styles.mb4]}>Due date</Text>
    185             <Text style={styles.body2}>{fDate(dueDate.toDate())}</Text>
     185            <Text style={styles.body2}>{fDate(dueDate)}</Text>
    186186          </View>
    187187        </View>
  • src/sections/invoice/invoice-new-edit-address.tsx

    r5d6f37a r057453c  
    77import Typography from '@mui/material/Typography';
    88// hooks
    9 import { useGetSettings } from 'src/api/settings';
     9import { useGetTenant } from 'src/api/tenant';
    1010import { useBoolean } from 'src/hooks/use-boolean';
    1111import { useResponsive } from 'src/hooks/use-responsive';
     
    3232  const { invoiceFrom, invoiceTo } = values;
    3333
    34   const { settings } = useGetSettings();
     34  const { settings: tenant } = useGetTenant();
    3535  const { customers } = useGetCustomers();
    3636
     
    100100      </Stack>
    101101
    102       {settings && (
     102      {tenant && (
    103103        <CompanyListDialog
    104104          title="Companies"
     
    106106          onClose={from.onFalse}
    107107          selected={(selectedId: string) => invoiceFrom?.id === selectedId}
    108           onSelect={(company) => setValue('invoiceFrom', company)}
    109           list={[settings?.company, settings?.['company-ee']]}
     108          onSelect={(tenant) => setValue('invoiceFrom', tenant)}
     109          tenant={tenant}
    110110          action={
    111111            <Button
  • src/sections/invoice/invoice-new-edit-details.tsx

    r5d6f37a r057453c  
    1414import { fCurrency } from 'src/utils/format-number';
    1515// types
    16 import { InvoiceItem } from 'mvpmasters-shared';
     16import { InvoiceItem } from 'src/schemas';
    1717// components
    1818import Iconify from 'src/components/iconify';
     
    7575  const handleSelectService = useCallback(
    7676    (index: number, option: string) => {
    77       setValue(
    78         `items[${index}].price`,
    79         invoiceServices.find((service) => service.id === option)?.price?.[
    80           values.quantityType?.toLowerCase() as 'sprint' | 'hour' | 'month'
    81         ]
    82       );
    83       setValue(
    84         `items[${index}].total`,
    85         values.items.map((item: InvoiceItem) => item.quantity * item.price)[index]
    86       );
     77      const service = invoiceServices.find((service) => service.id === option);
     78      if (!service) return;
     79
     80      const quantityType = values.quantityType?.toLowerCase();
     81      let price = 0;
     82
     83      switch (quantityType) {
     84        case 'sprint':
     85          price = service.sprint;
     86          break;
     87        case 'hour':
     88          price = service.hour;
     89          break;
     90        case 'month':
     91          price = service.month;
     92          break;
     93        default:
     94          price = 0;
     95      }
     96
     97      setValue(`items[${index}].price`, price);
     98      setValue(`items[${index}].total`, values.items[index].quantity * price);
    8799    },
    88100    [setValue, values.items, values.quantityType, invoiceServices]
  • src/sections/invoice/invoice-new-edit-form.tsx

    r5d6f37a r057453c  
    1111import { useRouter } from 'src/routes/hooks';
    1212// types
    13 import { Invoice } from 'mvpmasters-shared';
     13import { CreateInvoice, Invoice } from 'src/schemas';
    1414// hooks
    1515import { useBoolean } from 'src/hooks/use-boolean';
     
    2020import uploadToFirebaseStorage from 'src/utils/upload-to-firebase-storage';
    2121import { pdf } from '@react-pdf/renderer';
    22 import { useGetSettings } from 'src/api/settings';
    23 import {
    24   collections,
    25   documents,
    26   firestoreBatch,
    27   generateId,
    28   updateDocument,
    29 } from 'src/lib/firestore';
     22import { useGetTenant } from 'src/api/tenant';
     23import { collections, generateId, updateDocument } from 'src/lib/firestore';
    3024import { useGetServices } from 'src/api/service';
    3125import { mutate } from 'swr';
    32 import { Timestamp } from 'firebase/firestore';
    3326import InvoiceNewEditStatusDate from './invoice-new-edit-status-date';
    3427import InvoiceNewEditAddress from './invoice-new-edit-address';
    3528import InvoiceNewEditDetails from './invoice-new-edit-details';
    3629import InvoicePDF from './invoice-pdf';
     30import { createInvoice, updateInvoice } from 'src/api/invoice';
    3731
    3832// ----------------------------------------------------------------------
     
    5852];
    5953
     54interface InvoiceItem {
     55  service: string | null;
     56  title: string;
     57  price: number;
     58  total: number;
     59  quantity: number;
     60  description: string;
     61}
     62
    6063export default function InvoiceNewEditForm({ isCopy, currentInvoice }: Props) {
    6164  const router = useRouter();
     
    6972    description: Yup.string().required('Description is required'),
    7073    service: Yup.string().nullable(),
    71     quantity: Yup.number().required('Quantity is required').min(0.5, 'Quantity must be at least 1'),
     74    quantity: Yup.number()
     75      .required('Quantity is required')
     76      .min(0.5, 'Quantity must be at least 0.5'),
    7277    price: Yup.number().required('Price is required').min(0, 'Price must be at least 0'),
    7378    total: Yup.number().required('Total is required').min(0, 'Total must be at least 0'),
     
    9196      .required('Quantity type is required'),
    9297    month: Yup.string().oneOf(monthNames).required('Month is required'),
    93     status: Yup.string().required(),
     98    status: Yup.string().oneOf(['draft', 'processing', 'pending', 'overdue', 'paid']).required(),
    9499    totalAmount: Yup.number().required(),
    95100    // not required
     
    103108
    104109  const { services: invoiceServices } = useGetServices();
    105   const { settings, settingsEmpty, settingsLoading } = useGetSettings();
     110  console.log('invoiceServices', invoiceServices);
     111  const { settings: tenant, settingsEmpty, settingsLoading } = useGetTenant();
    106112
    107113  const defaultValues = useMemo(
     
    110116        !isCopy && currentInvoice?.invoiceNumber
    111117          ? currentInvoice?.invoiceNumber
    112           : incrementInvoiceNumber(settings?.invoice?.lastInvoiceNumber),
    113       createDate: currentInvoice?.createDate?.toDate() || new Date(),
    114       dueDate: currentInvoice?.dueDate?.toDate() || null,
     118          : incrementInvoiceNumber(tenant?.lastInvoiceNumber),
     119      createDate: currentInvoice?.createDate ? new Date(currentInvoice.createDate) : new Date(),
     120      dueDate: currentInvoice?.dueDate
     121        ? new Date(currentInvoice.dueDate)
     122        : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
    115123      invoiceFrom: currentInvoice?.invoiceFrom || null,
    116124      invoiceTo: currentInvoice?.invoiceTo || null,
     
    138146      totalAmount: currentInvoice?.totalAmount || 0,
    139147    }),
    140     [currentInvoice, isCopy, settings]
     148    [currentInvoice, isCopy, tenant]
    141149  );
    142150
     
    164172
    165173    try {
    166       // generate collection id
    167174      const id = generateId(collections.invoice);
     175
     176      // Ensure dates are valid Date objects
     177      const createDate =
     178        data.createDate instanceof Date ? data.createDate : new Date(data.createDate);
     179      const dueDate = data.dueDate instanceof Date ? data.dueDate : new Date(data.dueDate);
     180
     181      const currentTime = new Date();
     182      createDate.setHours(
     183        currentTime.getHours(),
     184        currentTime.getMinutes(),
     185        currentTime.getSeconds()
     186      );
    168187
    169188      // attach serivce details
     
    173192      }));
    174193
    175       const currentTime = new Date();
    176       const createDateWithCurrentTime = new Date(data.createDate); // This creates a date object using the date from data.createDate
    177       // Set the time of createDateWithCurrentTime to the current hour, minutes, and seconds
    178       createDateWithCurrentTime.setHours(
    179         currentTime.getHours(),
    180         currentTime.getMinutes(),
    181         currentTime.getSeconds()
    182       );
    183 
    184194      // transform data
    185       const writeData = {
     195      const writeData: CreateInvoice = {
    186196        ...data,
    187         invoiceNumber: incrementInvoiceNumber(settings?.invoice.lastInvoiceNumber),
     197        invoiceNumber: incrementInvoiceNumber(tenant?.lastInvoiceNumber),
    188198        status: 'draft',
    189         createDate: Timestamp.fromDate(createDateWithCurrentTime),
    190         dueDate: Timestamp.fromDate(new Date(data.dueDate)),
    191         items,
     199        createDate,
     200        dueDate,
     201        items: items.filter((item) => item.service !== null) as CreateInvoice['items'],
     202        subTotal: data.totalAmount - (data.taxes || 0) - (data.discount || 0),
     203        month: data.month as Invoice['month'],
     204        invoiceFrom: data.invoiceFrom!,
     205        invoiceTo: data.invoiceTo!,
    192206      };
    193207
     
    199213
    200214      // write to DB
    201       await firestoreBatch([
    202         {
    203           docPath: `${collections.invoice}/${id}`,
    204           type: 'set',
    205           data: {
    206             ...writeData,
    207             pdfRef: storagePath,
    208           },
    209         },
    210         {
    211           docPath: `${collections.settings}/${documents.settingsInvoice}`,
    212           type: 'set',
    213           data: { lastInvoiceNumber: writeData.invoiceNumber },
    214         },
    215       ]);
     215      // await firestoreBatch([
     216      //   {
     217      //     docPath: `${collections.invoice}/${id}`,
     218      //     type: 'set',
     219      //     data: {
     220      //       ...writeData,
     221      //       pdfRef: storagePath,
     222      //     },
     223      //   },
     224      //   {
     225      //     docPath: `${collections.settings}/${documents.settingsInvoice}`,
     226      //     type: 'set',
     227      //     data: { lastInvoiceNumber: writeData.invoiceNumber },
     228      //   },
     229      // ]);
     230
     231      await createInvoice({ ...writeData, pdfRef: storagePath });
    216232
    217233      loadingSave.onFalse();
     
    229245    try {
    230246      if (currentInvoice) {
     247        // Ensure dates are valid Date objects
     248        const createDate =
     249          data.createDate instanceof Date ? data.createDate : new Date(data.createDate);
     250        const dueDate = data.dueDate instanceof Date ? data.dueDate : new Date(data.dueDate);
     251
    231252        // attach serivce details
    232253        const items = data.items.map((item) => ({
     
    238259        const writeData = {
    239260          ...data,
    240           createDate: Timestamp.fromDate(new Date(data.createDate)),
    241           dueDate: Timestamp.fromDate(new Date(data.dueDate)),
     261          createDate,
     262          dueDate,
    242263          items,
     264          invoiceFrom: data.invoiceFrom!,
     265          invoiceTo: data.invoiceTo!,
    243266        };
    244267
     
    250273
    251274        // update DB
    252         await updateDocument(collections.invoice, currentInvoice.id, {
     275        // await updateDocument(collections.invoice, currentInvoice.id, {
     276        //   ...writeData,
     277        //   pdfRef: storagePath,
     278        // });
     279
     280        await updateInvoice(currentInvoice.id, {
    253281          ...writeData,
    254282          pdfRef: storagePath,
     283          status: data.status as 'draft' | 'processing' | 'pending' | 'overdue' | 'paid',
     284          month: data.month as
     285            | 'January'
     286            | 'February'
     287            | 'March'
     288            | 'April'
     289            | 'May'
     290            | 'June'
     291            | 'July'
     292            | 'August'
     293            | 'September'
     294            | 'October'
     295            | 'November'
     296            | 'December',
     297          items: items.filter((item) => item.service !== null) as {
     298            service: { id: string; month: number; name: string; sprint: number; hour: number };
     299            title: string;
     300            price: number;
     301            total: number;
     302            quantity: number;
     303            description: string;
     304          }[],
    255305        });
    256306
    257307        // mutate current data
    258         mutate([collections.invoice, currentInvoice.id]);
     308        // mutate([collections.invoice, currentInvoice.id]);
    259309      } else {
    260310        // generate collection id
    261311        const id = generateId(collections.invoice);
     312
     313        // Ensure dates are valid Date objects
     314        const createDate =
     315          data.createDate instanceof Date ? data.createDate : new Date(data.createDate);
     316        const dueDate = data.dueDate instanceof Date ? data.dueDate : new Date(data.dueDate);
    262317
    263318        // attach serivce details
     
    270325        const writeData = {
    271326          ...data,
    272           createDate: Timestamp.fromDate(new Date(data.createDate)),
    273           dueDate: Timestamp.fromDate(new Date(data.dueDate)),
     327          createDate,
     328          dueDate,
    274329          items,
     330          invoiceFrom: data.invoiceFrom!,
     331          invoiceTo: data.invoiceTo!,
    275332        };
    276333
     
    282339
    283340        // write to DB
    284         await firestoreBatch([
    285           {
    286             docPath: `${collections.invoice}/${id}`,
    287             type: 'set',
    288             data: {
    289               ...writeData,
    290               pdfRef: storagePath,
    291             },
    292           },
    293           {
    294             docPath: `${collections.settings}/${documents.settingsInvoice}`,
    295             type: 'set',
    296             data: { lastInvoiceNumber: writeData.invoiceNumber },
    297           },
    298         ]);
     341        // await firestoreBatch([
     342        //   {
     343        //     docPath: `${collections.invoice}/${id}`,
     344        //     type: 'set',
     345        //     data: {
     346        //       ...writeData,
     347        //       pdfRef: storagePath,
     348        //     },
     349        //   },
     350        //   {
     351        //     docPath: `${collections.settings}/${documents.settingsInvoice}`,
     352        //     type: 'set',
     353        //     data: { lastInvoiceNumber: writeData.invoiceNumber },
     354        //   },
     355        // ]);
     356        await createInvoice({ ...writeData, pdfRef: storagePath });
    299357
    300358        reset();
     
    314372    <FormProvider methods={methods}>
    315373      <Card>
    316         {settings?.company && <InvoiceNewEditAddress />}
     374        {!!tenant && <InvoiceNewEditAddress />}
    317375
    318376        <InvoiceNewEditStatusDate isCopy={isCopy} />
  • src/sections/invoice/invoice-new-edit-status-date.tsx

    r5d6f37a r057453c  
    77import { RHFSelect, RHFTextField } from 'src/components/hook-form';
    88// api
    9 import { useGetSettings } from 'src/api/settings';
     9// import { useGetTenant } from 'src/api/settings';
    1010// utils
    1111import { incrementInvoiceNumber } from 'src/utils/increment-invoice-number';
     
    3333  const values = watch();
    3434
    35   const { settings } = useGetSettings();
     35  // const { settings } = useGetTenant();
    3636
    3737  return (
  • src/sections/invoice/invoice-pdf.tsx

    r5d6f37a r057453c  
    1 import { Invoice } from 'mvpmasters-shared';
     1import { Invoice } from 'src/schemas';
    22import InvoiceEEPDF from './invoice-ee-pdf';
    33import InvoiceMKPDF from './invoice-mk-pdf';
  • src/sections/invoice/invoice-table-filters-result.tsx

    r5d6f37a r057453c  
    66import Stack, { StackProps } from '@mui/material/Stack';
    77// types
    8 import { InvoiceTableFilters, InvoiceTableFilterValue } from 'mvpmasters-shared';
     8import { InvoiceTableFilters, InvoiceTableFilterValue } from 'src/schemas';
    99// components
    1010import Iconify from 'src/components/iconify';
  • src/sections/invoice/invoice-table-row.tsx

    r5d6f37a r057453c  
    1616import { fNumber } from 'src/utils/format-number';
    1717// types
    18 import { Invoice } from 'mvpmasters-shared';
     18import { Invoice } from 'src/schemas';
    1919// components
    2020import Label from 'src/components/label';
     
    6060  } = row;
    6161
     62  console.log(createDate);
     63
    6264  const confirmSend = useBoolean();
    6365  const confirmDelete = useBoolean();
     
    105107        <TableCell>
    106108          <ListItemText
    107             primary={format(createDate.toDate(), 'dd MMM yyyy')}
    108             secondary={format(createDate.toDate(), 'p')}
     109            primary={format(new Date(createDate), 'dd MMM yyyy')}
     110            secondary={format(new Date(createDate), 'p')}
    109111            primaryTypographyProps={{ typography: 'body2', noWrap: true }}
    110112            secondaryTypographyProps={{
     
    118120        <TableCell>
    119121          <ListItemText
    120             primary={format(dueDate.toDate(), 'dd MMM yyyy')}
    121             secondary={format(dueDate.toDate(), 'p')}
     122            primary={format(new Date(dueDate), 'dd MMM yyyy')}
     123            secondary={format(new Date(dueDate), 'p')}
    122124            primaryTypographyProps={{ typography: 'body2', noWrap: true }}
    123125            secondaryTypographyProps={{
  • src/sections/invoice/invoice-table-toolbar.tsx

    r5d6f37a r057453c  
    77import InputAdornment from '@mui/material/InputAdornment';
    88// types
    9 import { InvoiceTableFilters, InvoiceTableFilterValue } from 'mvpmasters-shared';
     9import { InvoiceTableFilters, InvoiceTableFilterValue } from 'src/schemas';
    1010// components
    1111import Iconify from 'src/components/iconify';
  • src/sections/invoice/invoice-toolbar.tsx

    r5d6f37a r057453c  
    1818import { useBoolean } from 'src/hooks/use-boolean';
    1919// types
    20 import { Invoice } from 'mvpmasters-shared';
     20import { Invoice } from 'src/schemas';
    2121// components
    2222import Iconify from 'src/components/iconify';
  • src/sections/invoice/mail-compose.tsx

    r5d6f37a r057453c  
    1313import Iconify from 'src/components/iconify';
    1414import Editor from 'src/components/editor';
    15 import { Invoice } from 'mvpmasters-shared';
     15import { Invoice } from 'src/schemas';
    1616import FormProvider from 'src/components/hook-form/form-provider';
    1717import { RHFTextField } from 'src/components/hook-form';
  • src/sections/invoice/view/invoice-edit-view.tsx

    r5d6f37a r057453c  
    2323
    2424  const { currentInvoice } = useGetInvoice({ id });
     25  console.log('currentInvoice', currentInvoice);
    2526
    2627  return (
  • src/sections/invoice/view/invoice-list-view.tsx

    r5d6f37a r057453c  
    3636} from 'src/components/table';
    3737// types
    38 import {
    39   Invoice,
    40   InvoiceStatus,
    41   InvoiceTableFilters,
    42   InvoiceTableFilterValue,
    43 } from 'mvpmasters-shared';
     38import { Invoice, InvoiceStatus, InvoiceTableFilters, InvoiceTableFilterValue } from 'src/schemas';
    4439//
    4540import deleteFromFirebaseStorage from 'src/utils/delete-from-firebase-storage';
    4641// fetch
    47 import { useGetInvoices } from 'src/api/invoice';
     42import { useDeleteInvoice, useGetInvoices } from 'src/api/invoice';
    4843import { collections, removeDocument } from 'src/lib/firestore';
    4944import { mutate } from 'swr';
     
    163158  const [filters, setFilters] = useState(defaultFilters);
    164159
    165   const { invoices: tableData } = useGetInvoices({
    166     where: [['createDate', '>=', filters.startDate]],
    167     orderBy: 'createDate',
    168     direction: 'desc',
    169   });
     160  const { invoices: tableData } = useGetInvoices({ startDate: filters.startDate?.toISOString() });
    170161
    171162  const invoiceMutationKey = useMemo(
     
    269260  );
    270261
     262  const { deleteInvoiceMutation } = useDeleteInvoice();
     263
    271264  const handleDeleteRow = useCallback(
    272265    async (invoice: Invoice) => {
     
    275268        orderBy: 'createDate',
    276269        direction: 'desc',
    277       }); // Get the same params as used in useGetInvoices
    278 
    279       // Optimistically update the cache before the deletion
    280       // mutate(
    281       //   [collections.invoice, serializedParams],
    282       //   (invoices: Invoice[] = []) => invoices.filter((row) => row.id !== invoice.id),
    283       //   false
    284       // );
    285 
    286       await removeDocument(collections.invoice, invoice.id);
     270      });
     271
     272      await deleteInvoiceMutation(invoice.id);
    287273      await deleteFromFirebaseStorage(
    288274        `invoices/${invoice.invoiceTo.name}/${invoice.id}-${invoice.invoiceNumber}.pdf`
    289275      );
    290276
    291       // Optionally, rollback optimistic update or refetch data
    292277      mutate(invoiceMutationKey);
    293278    },
     
    672657      inputData = inputData.filter(
    673658        (invoice) =>
    674           fTimestamp(invoice.createDate.toMillis()) >= fTimestamp(startDate) &&
    675           fTimestamp(invoice.createDate.toMillis()) <= fTimestamp(endDate)
     659          fTimestamp(invoice.createDate.getTime()) >= fTimestamp(startDate.getTime()) &&
     660          fTimestamp(invoice.createDate.getTime()) <= fTimestamp(endDate.getTime())
    676661      );
    677662    }
  • src/sections/user/customer-new-edit-form.tsx

    r5d6f37a r057453c  
    1818import { useRouter } from 'src/routes/hooks';
    1919// types
    20 import { Customer, NewCustomer, newCustomerSchema } from 'mvpmasters-shared';
     20import { Customer, NewCustomer, newCustomerSchema } from 'src/schemas';
    2121// components
    2222import Label from 'src/components/label';
  • src/sections/user/customer-quick-edit-form.tsx

    r5d6f37a r057453c  
    1414import DialogContent from '@mui/material/DialogContent';
    1515// types
    16 import { Customer, customerSchema } from 'mvpmasters-shared';
     16import { Customer, customerSchema } from 'src/schemas';
    1717// assets
    1818import { countries } from 'src/assets/data';
  • src/sections/user/customer-table-filters-result.tsx

    r5d6f37a r057453c  
    99// components
    1010import Iconify from 'src/components/iconify';
    11 import { CustomerTableFilterValue, CustomerTableFilters } from 'mvpmasters-shared';
     11import { CustomerTableFilterValue, CustomerTableFilters } from 'src/schemas';
    1212
    1313// ----------------------------------------------------------------------
  • src/sections/user/customer-table-row.tsx

    r5d6f37a r057453c  
    99import { useBoolean } from 'src/hooks/use-boolean';
    1010// types
    11 import { Customer } from 'mvpmasters-shared';
     11import { Customer } from 'src/schemas';
    1212// components
    1313import Label from 'src/components/label';
  • src/sections/user/customer-table-toolbar.tsx

    r5d6f37a r057453c  
    1212import Select, { SelectChangeEvent } from '@mui/material/Select';
    1313// types
    14 import { CustomerTableFilters, CustomerTableFilterValue } from 'mvpmasters-shared';
     14import { CustomerTableFilters, CustomerTableFilterValue } from 'src/schemas';
    1515// components
    1616import Iconify from 'src/components/iconify';
  • src/sections/user/view/customer-list-view.tsx

    r5d6f37a r057453c  
    3434} from 'src/components/table';
    3535// types
    36 import { Customer, CustomerTableFilters, CustomerTableFilterValue } from 'mvpmasters-shared';
     36import { Customer, CustomerTableFilters, CustomerTableFilterValue } from 'src/schemas';
    3737//
    3838import { useGetCustomers } from 'src/api/customer';
  • src/utils/axios.ts

    r5d6f37a r057453c  
    4545
    4646export const endpoints = {
    47   invoice: '/api/invoice',
     47  invoice: '/api/invoices',
    4848  customer: '/api/customers',
     49  tenant: '/api/tenant',
     50  service: '/api/services',
     51  employee: '/api/employees',
    4952};
  • src/utils/create-full-address.ts

    r5d6f37a r057453c  
    1 import { Address } from 'mvpmasters-shared';
     1import { Address } from 'src/schemas';
    22
    33export function createFullAddress(data: Address): string {
  • src/utils/get-invoice-quantity-type.ts

    r5d6f37a r057453c  
    1 import { Invoice } from 'mvpmasters-shared';
     1import { Invoice } from 'src/schemas';
    22
    33export const getQuantityType = (invoice: Invoice) => {
  • yarn.lock

    r5d6f37a r057453c  
    15341534  integrity sha512-HxjQ8Qn+4SI3/AFv6sOrDB+g6PpUTDwSJiQqOrnneEk8L71161srI9gjzzZvYVbzHiVg/BvcH95+cK/zfIt4pg==
    15351535
    1536 "@fastify/busboy@^2.0.0":
    1537   version "2.1.1"
    1538   resolved "https://registry.yarnpkg.com/@fastify/busboy/-/busboy-2.1.1.tgz#b9da6a878a371829a0502c9b6c1c143ef6663f4d"
    1539   integrity sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==
    1540 
    15411536"@fastify/busboy@^3.0.0":
    15421537  version "3.1.1"
     
    16401635    tslib "^2.1.0"
    16411636
    1642 "@firebase/app@^0.9.25":
    1643   version "0.9.29"
    1644   resolved "https://registry.yarnpkg.com/@firebase/app/-/app-0.9.29.tgz#444280f0ddf1da4b2a974c86a6a8c6405d950fb7"
    1645   integrity sha512-HbKTjfmILklasIu/ij6zKnFf3SgLYXkBDVN7leJfVGmohl+zA7Ig+eXM1ZkT1pyBJ8FTYR+mlOJer/lNEnUCtw==
    1646   dependencies:
    1647     "@firebase/component" "0.6.5"
    1648     "@firebase/logger" "0.4.0"
    1649     "@firebase/util" "1.9.4"
    1650     idb "7.1.1"
    1651     tslib "^2.1.0"
    1652 
    16531637"@firebase/auth-compat@0.4.6":
    16541638  version "0.4.6"
     
    17031687  dependencies:
    17041688    "@firebase/util" "1.9.3"
    1705     tslib "^2.1.0"
    1706 
    1707 "@firebase/component@0.6.5":
    1708   version "0.6.5"
    1709   resolved "https://registry.yarnpkg.com/@firebase/component/-/component-0.6.5.tgz#8cc7334f2081d700f2769caaa8dae3ac4c1fe37e"
    1710   integrity sha512-2tVDk1ixi12sbDmmfITK8lxSjmcb73BMF6Qwc3U44hN/J1Fi1QY/Hnnb6klFlbB9/G16a3J3d4nXykye2EADTw==
    1711   dependencies:
    1712     "@firebase/util" "1.9.4"
    1713     tslib "^2.1.0"
    1714 
    1715 "@firebase/component@0.6.6":
    1716   version "0.6.6"
    1717   resolved "https://registry.yarnpkg.com/@firebase/component/-/component-0.6.6.tgz#7ad4013ff37686d355dee0694f1fa4604491b7c3"
    1718   integrity sha512-pp7sWqHmAAlA3os6ERgoM3k5Cxff510M9RLXZ9Mc8KFKMBc2ct3RkZTWUF7ixJNvMiK/iNgRLPDrLR2gtRJ9iQ==
    1719   dependencies:
    1720     "@firebase/util" "1.9.5"
    17211689    tslib "^2.1.0"
    17221690
     
    18011769  resolved "https://registry.yarnpkg.com/@firebase/firestore-types/-/firestore-types-3.0.0.tgz#f3440d5a1cc2a722d361b24cefb62ca8b3577af3"
    18021770  integrity sha512-Meg4cIezHo9zLamw0ymFYBD4SMjLb+ZXIbuN7T7ddXN6MGoICmOTq3/ltdCGoDCS2u+H1XJs2u/cYp75jsX9Qw==
    1803 
    1804 "@firebase/firestore-types@^3.0.0":
    1805   version "3.0.1"
    1806   resolved "https://registry.yarnpkg.com/@firebase/firestore-types/-/firestore-types-3.0.1.tgz#5b802f8aeffe6803f594b428054b10e25a73ae66"
    1807   integrity sha512-mVhPcHr5FICjF67m6JHgj+XRvAz/gZ62xifeGfcm00RFl6tNKfCzCfKeyB2BDIEc9dUnEstkmIXlmLIelOWoaA==
    18081771
    18091772"@firebase/firestore@4.2.0":
     
    18211784    tslib "^2.1.0"
    18221785
    1823 "@firebase/firestore@^4.4.0":
    1824   version "4.6.1"
    1825   resolved "https://registry.yarnpkg.com/@firebase/firestore/-/firestore-4.6.1.tgz#4a788103bca46e3ee829ddb816189f23cd26212a"
    1826   integrity sha512-MaBOBu+QcZOp6SJzCmigiJ4Dt0HNic91w8GghbTE9L//VW/zdO7ezXrcXRK4TjWWOcazBrJZJSHTIsFdwZyvtQ==
    1827   dependencies:
    1828     "@firebase/component" "0.6.6"
    1829     "@firebase/logger" "0.4.1"
    1830     "@firebase/util" "1.9.5"
    1831     "@firebase/webchannel-wrapper" "0.10.6"
    1832     "@grpc/grpc-js" "~1.9.0"
    1833     "@grpc/proto-loader" "^0.7.8"
    1834     tslib "^2.1.0"
    1835     undici "5.28.4"
    1836 
    18371786"@firebase/functions-compat@0.3.5":
    18381787  version "0.3.5"
     
    18971846    tslib "^2.1.0"
    18981847
    1899 "@firebase/logger@0.4.1":
    1900   version "0.4.1"
    1901   resolved "https://registry.yarnpkg.com/@firebase/logger/-/logger-0.4.1.tgz#b4bb0d266680210a34e7c70a8a92342391e399ab"
    1902   integrity sha512-tTIixB5UJbG9ZHSGZSZdX7THr3KWOLrejZ9B7jYsm6fpwgRNngKznQKA2wgYVyvBc1ta7dGFh9NtJ8n7qfiYIw==
    1903   dependencies:
    1904     tslib "^2.1.0"
    1905 
    19061848"@firebase/logger@0.4.4":
    19071849  version "0.4.4"
     
    20341976    tslib "^2.1.0"
    20351977
    2036 "@firebase/util@1.9.4":
    2037   version "1.9.4"
    2038   resolved "https://registry.yarnpkg.com/@firebase/util/-/util-1.9.4.tgz#68eee380ab7e7828ec0d8684c46a1abed2d7e334"
    2039   integrity sha512-WLonYmS1FGHT97TsUmRN3qnTh5TeeoJp1Gg5fithzuAgdZOUtsYECfy7/noQ3llaguios8r5BuXSEiK82+UrxQ==
    2040   dependencies:
    2041     tslib "^2.1.0"
    2042 
    2043 "@firebase/util@1.9.5", "@firebase/util@^1.5.1":
    2044   version "1.9.5"
    2045   resolved "https://registry.yarnpkg.com/@firebase/util/-/util-1.9.5.tgz#9321fc1695b30a9283c99f768da2e837e02cbddc"
    2046   integrity sha512-PP4pAFISDxsf70l3pEy34Mf3GkkUcVQ3MdKp6aSVb7tcpfUQxnsdV7twDd8EkfB6zZylH6wpUAoangQDmCUMqw==
    2047   dependencies:
    2048     tslib "^2.1.0"
    2049 
    20501978"@firebase/webchannel-wrapper@0.10.3":
    20511979  version "0.10.3"
    20521980  resolved "https://registry.yarnpkg.com/@firebase/webchannel-wrapper/-/webchannel-wrapper-0.10.3.tgz#c894a21e8c911830e36bbbba55903ccfbc7a7e25"
    20531981  integrity sha512-+ZplYUN3HOpgCfgInqgdDAbkGGVzES1cs32JJpeqoh87SkRobGXElJx+1GZSaDqzFL+bYiX18qEcBK76mYs8uA==
    2054 
    2055 "@firebase/webchannel-wrapper@0.10.6":
    2056   version "0.10.6"
    2057   resolved "https://registry.yarnpkg.com/@firebase/webchannel-wrapper/-/webchannel-wrapper-0.10.6.tgz#443efa9f761fd4ed8ca60c353e3a44d3a47e81f1"
    2058   integrity sha512-EnfRJvrnzkHwN3BPMCayCFT5lCqInzg3RdlRsDjDvB1EJli6Usj26T6lJ67BU2UcYXBS5xcp1Wj4+zRzj2NaZg==
    20591982
    20601983"@floating-ui/core@^1.4.2":
     
    74747397  integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
    74757398
    7476 moment@^2.29.4:
    7477   version "2.30.1"
    7478   resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae"
    7479   integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==
    7480 
    74817399ms@2.0.0:
    74827400  version "2.0.0"
     
    75167434    arrify "^2.0.1"
    75177435    minimatch "^3.0.4"
    7518 
    7519 mvpmasters-shared@^1.0.9:
    7520   version "1.0.9"
    7521   resolved "https://registry.yarnpkg.com/mvpmasters-shared/-/mvpmasters-shared-1.0.9.tgz#964127bb04e25dcdfceab1aa490e106300bf430d"
    7522   integrity sha512-RluBz76NR5Q2yd25nWgKIuPAWHUa75U4uwR6e7iFw6zFIn+b83fE0upbUY7+iRFF5SSTFfj/7Uk5zZffy1HLlQ==
    7523   dependencies:
    7524     "@firebase/app" "^0.9.25"
    7525     "@firebase/firestore" "^4.4.0"
    7526     "@firebase/firestore-types" "^3.0.0"
    7527     "@firebase/util" "^1.5.1"
    7528     lodash "^4.17.21"
    7529     moment "^2.29.4"
    7530     zod "^3.22.4"
    75317436
    75327437nanoid@^3.3.6:
     
    95139418    is-typed-array "^1.1.9"
    95149419
    9515 typescript@^5.2.2:
    9516   version "5.2.2"
    9517   resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.2.tgz#5ebb5e5a5b75f085f22bc3f8460fba308310fa78"
    9518   integrity sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==
     9420typescript@5.7.3:
     9421  version "5.7.3"
     9422  resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.7.3.tgz#919b44a7dbb8583a9b856d162be24a54bf80073e"
     9423  integrity sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==
    95199424
    95209425unbox-primitive@^1.0.2:
     
    95329437  resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433"
    95339438  integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==
    9534 
    9535 undici@5.28.4:
    9536   version "5.28.4"
    9537   resolved "https://registry.yarnpkg.com/undici/-/undici-5.28.4.tgz#6b280408edb6a1a604a9b20340f45b422e373068"
    9538   integrity sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==
    9539   dependencies:
    9540     "@fastify/busboy" "^2.0.0"
    95419439
    95429440unicode-canonical-property-names-ecmascript@^2.0.0:
     
    99579855  resolved "https://registry.yarnpkg.com/zod/-/zod-3.20.2.tgz#068606642c8f51b3333981f91c0a8ab37dfc2807"
    99589856  integrity sha512-1MzNQdAvO+54H+EaK5YpyEy0T+Ejo/7YLHS93G3RnYWh5gaotGHwGeN/ZO687qEDU2y4CdStQYXVHIgrUl5UVQ==
    9959 
    9960 zod@^3.22.4:
    9961   version "3.23.5"
    9962   resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.5.tgz#c7b7617d017d4a2f21852f533258d26a9a5ae09f"
    9963   integrity sha512-fkwiq0VIQTksNNA131rDOsVJcns0pfVUjHzLrNBiF/O/Xxb5lQyEXkhZWcJ7npWsYlvs+h0jFWXXy4X46Em1JA==
Note: See TracChangeset for help on using the changeset viewer.